t0ul

Ship, Securely, Ch. 5: Kubernetes Runtime Security

Part of Ship, Securely, a hands-on DevSecOps book.

Chapter 5. Kubernetes Configuration Testing

Why this step

Catching issues in infra/k8s/*.yaml before kubectl apply is cheap and fast. These tools complement each other:

  • kube-linter: opinionated rules → SARIF (great for PR review).
  • kube-score: human-readable “why” explanations.
  • Checkov: policy-as-code across K8s/Terraform/etc. (already in CI).

Run it

# from repo root
make k8s-audit

What you’ll see

  • kube-linter.sarif with rule IDs & resource paths.
  • kube-score.txt with “critical/objective” hints.
  • checkov.json from earlier CI runs.

Fix-it focus (quick wins)

  • Add securityContext with runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false.
  • Set resources.requests/limits for each container.
  • Avoid :latest images; pin a tag or digest.
  • Ensure livenessProbe and readinessProbe are present.

Checklist

  • make k8s-audit produced all three reports
  • You fixed at least 2 misconfigs and re-ran the audit
  • SARIF is viewable in your editor (e.g., VS Code SARIF viewer)

Chapter 5. Kyverno

Run the test

make kyverno-install
make kyverno-apply
make kyverno-policy-test

This attempts to apply infra/k8s/bad/deploy-bad.yaml and saves the exact kubectl error output to artifacts/policy-tests.txt.

How to read the output

A typical rejection looks like:

Error from server: error when creating "deploy-bad.yaml":
admission webhook "validate.kyverno.svc" denied the request:

policy disallow-latest-tags, rule no-latest:
  validation error: Do not use :latest image tags.
  resource: Deployment/ship/bad-app, field: spec.template.spec.containers[0].image

policy enforce-pod-security-basics, rule require-seccontext:
  validation error: Harden containers: non-root, no privilege escalation, read-only root FS.
  resource: Deployment/ship/bad-app, field: spec.template.spec.containers[0].securityContext

Map the pieces:

  • policy → which file in policies/ matched
  • rule → rule name in that file
  • validation error → the message you wrote in the policy
  • resource → kind/namespace/name that failed
  • field → the exact path to fix

Evidence tip

Attach artifacts/policy-tests.txt to your PR (or include it in the evidence zip). Reviewers see which policy blocked which field and why.

Checklist

  • Bad manifest rejected
  • Evidence saved to artifacts/policy-tests.txt
  • You can point to the exact field to change

Chapter 5. Admission Policies with Kyverno

Why this step

Linters warn, admission enforces. Policies prevent accidental drift (“works on my laptop”) from landing in the cluster.

Install Kyverno

make kyverno-install

Uses Helm in the Makefile. If you prefer official manifests, apply them instead.

Apply policies

Ensure the following are present under policies/:

  • disallow-latest.yaml
  • enforce-pod-security-basics.yaml
  • require-resources.yaml
  • require-probes.yaml
  • (optional) verify-image-signatures.yaml (paste your cosign.pub)

Then:

make kyverno-apply

Prove it works (intentional failure)

A deliberately non-compliant manifest lives at infra/k8s/bad/deploy-bad.yaml. Run:

make kyverno-bad-apply

Expected: kubectl shows one or more validation errors. The output is copied to artifacts/policy-tests.txt as evidence.


(Optional) Verify image signatures

If you sign locally:

cosign generate-key-pair        # once
make sbom sign_blob verify_blob # proves sbom signature
# If you also sign the IMAGE later:
# cosign sign --key cosign.key $(GOOD)
# Configure verify-image-signatures.yaml with your public key and re-apply.

Checklist

  • Kyverno installed and policies/*.yaml committed
  • A bad manifest was rejected; evidence saved to artifacts/policy-tests.txt
  • (Optional) verify-image-signatures policy references your cosign public key

Chapter 5. RBAC for Least Privileges

Where should “RBAC Least Privilege” live?

  • Keep RBAC as its own short chapter (Ops hardening) with hands-on Role/RoleBinding/SA steps. It’s a foundational Kubernetes control and applies whether or not Kyverno is present.

  • Reference Kyverno as an optional guardrail at the end of that chapter (e.g., “disallow wildcard RBAC”), since you already use Kyverno earlier. That keeps the learning path incremental:

    1. grant least privilege (RBAC files)
    2. verify with kubectl auth can-i
    3. optionally enforce via Kyverno policy

Minimal Makefile additions (fit your style)

These targets don’t change any existing flow; they’re opt-in helpers.

# ---- RBAC (namespace-scoped least privilege) ----
k8s-rbac:
	@echo "== Apply app ServiceAccount + Role + RoleBinding =="
	@kubectl apply -f $(K8S_DIR)/rbac/sa.yaml
	@kubectl apply -f $(K8S_DIR)/rbac/role.yaml
	@kubectl apply -f $(K8S_DIR)/rbac/binding.yaml

rbac-verify:
	@echo "== RBAC can-i checks (evidence) =="
	@mkdir -p artifacts
	@{ \
	  date; \
	  echo "# RBAC verify (serviceaccount $(NAMESPACE):app-sa)"; \
	  kubectl -n $(NAMESPACE) auth can-i get pods     --as=system:serviceaccount:$(NAMESPACE):app-sa; \
	  kubectl -n $(NAMESPACE) auth can-i list pods    --as=system:serviceaccount:$(NAMESPACE):app-sa; \
	  kubectl -n $(NAMESPACE) auth can-i watch pods   --as=system:serviceaccount:$(NAMESPACE):app-sa; \
	  kubectl -n $(NAMESPACE) auth can-i create pods  --as=system:serviceaccount:$(NAMESPACE):app-sa; \
	  kubectl -n $(NAMESPACE) auth can-i get secrets  --as=system:serviceaccount:$(NAMESPACE):app-sa; \
	  kubectl -n kube-system auth can-i list pods     --as=system:serviceaccount:$(NAMESPACE):app-sa; \
	} | tee artifacts/rbac-verify.txt
	@echo "wrote artifacts/rbac-verify.txt"

RBAC files (namespace $(NAMESPACE))

infra/k8s/rbac/sa.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: ship
automountServiceAccountToken: true

infra/k8s/rbac/role.yaml

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: app-read
  namespace: ship
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get"]

infra/k8s/rbac/binding.yaml

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-read-binding
  namespace: ship
subjects:
  - kind: ServiceAccount
    name: app-sa
    namespace: ship
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: app-read

And add to your Deployment (already present file):

spec:
  template:
    spec:
      serviceAccountName: app-sa

Optional: Kyverno guardrail for RBAC (put in your existing policies/)

Since you already install/apply Kyverno, add a simple policy to block wildcard RBAC. This belongs in the Kyverno section and you can link it from the RBAC chapter as “turn on this safety net”.

policies/disallow-rbac-wildcards.yaml

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-rbac-wildcards
spec:
  validationFailureAction: enforce
  background: true
  rules:
    - name: no-wildcard-role
      match:
        any:
          - resources:
              kinds: ["Role","ClusterRole"]
      validate:
        message: "Avoid wildcard RBAC; specify explicit verbs/resources."
        pattern:
          spec:
            rules:
              - verbs: "!*=*"
                resources: "!*=*"

Wire it with your existing target (no new targets needed):

# just run:
make kyverno-apply

Chapter 5. Kubernetes Network Policies

— Default-Deny (with Tests)

Prereqs

  • Your app is deployed in namespace ship, Service name app (adjust if different).
  • Your Minikube profile has a CNI that enforces NetworkPolicies (e.g., Cilium or Calico).

If you haven’t enabled one yet, run a CNI addon that supports NetworkPolicy. (Minikube supports popular CNIs; pick one available on your setup.)

1) Capture service info

export NS=ship
export SVC=app
# Replace with your service port if not 8080:
export PORT=8080

kubectl -n $NS get svc $SVC -o wide

2) Apply a namespace default-deny

Create infra/k8s/netpol-default-deny.yaml:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: ship
spec:
  podSelector: {}                   # all Pods in namespace
  policyTypes: ["Ingress","Egress"] # deny everything by default
mkdir -p artifacts/netpol-manifests
kubectl apply -f infra/k8s/netpol-default-deny.yaml | tee artifacts/netpol-manifests/apply.txt

3) Launch a test client and try to curl (should be blocked)

# A temporary debug pod without any special labels
kubectl -n $NS run netshoot --restart=Never --image=nicolaka/netshoot -it --rm -- \
  /bin/sh -lc "apk add --no-cache curl >/dev/null 2>&1 || true; \
               echo 'curling cluster service…'; \
               curl -sS --max-time 5 http://$SVC.$NS.svc.cluster.local:$PORT/healthz || echo 'BLOCKED'"

Save output:

kubectl -n $NS run netshoot --restart=Never --image=nicolaka/netshoot --command -- sleep 5 2>/dev/null || true
kubectl -n $NS delete pod netshoot 2>/dev/null || true

Append your curl attempt (stdout/stderr) to ./artifacts/netpol-test.txt:

echo -e "\n--- BLOCK attempt $(date) ---" >> artifacts/netpol-test.txt
# (paste the curl output from the interactive run here)

Expected: timeout or connection refused → blocked.

4) Allow traffic only from labeled clients to the app Pods

Label your app Pods (if not already):

kubectl -n $NS label deploy/$SVC app=$SVC --overwrite

Create infra/k8s/netpol-allow-app-from-labeled.yaml:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-app-from-labeled
  namespace: ship
spec:
  podSelector:
    matchLabels:
      app: app          # target: your app Pods (adjust label if needed)
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              access: allowed
      ports:
        - protocol: TCP
          port: 8080    # adjust to your app containerPort/Service port

Apply it:

kubectl apply -f infra/k8s/netpol-allow-app-from-labeled.yaml \
  | tee -a artifacts/netpol-manifests/apply.txt

5) Test again (first blocked, then allowed)

Create an unlabeled client (still blocked):

kubectl -n $NS run np-client --restart=Never --image=nicolaka/netshoot -- \
  /bin/sh -lc "apk add --no-cache curl >/dev/null 2>&1 || true; \
               curl -sS --max-time 5 http://$SVC.$NS.svc.cluster.local:$PORT/healthz || echo BLOCKED" \
  | tee -a artifacts/netpol-test.txt
kubectl -n $NS delete pod np-client --now

Create an allowed client and label it:

kubectl -n $NS run np-allowed --restart=Never --image=nicolaka/netshoot -- /bin/sh -lc "sleep 3600" >/dev/null 2>&1 &
sleep 2
kubectl -n $NS label pod np-allowed access=allowed --overwrite
kubectl -n $NS exec np-allowed -- /bin/sh -lc \
  "apk add --no-cache curl >/dev/null 2>&1 || true; \
   curl -sS --max-time 5 http://$SVC.$NS.svc.cluster.local:$PORT/healthz" \
  | tee -a artifacts/netpol-test.txt
kubectl -n $NS delete pod np-allowed --now

Expected: first attempt BLOCKED, second attempt 200 OK.


Makefile helpers (optional)

netpol-apply:
	kubectl apply -f infra/k8s/netpol-default-deny.yaml
	kubectl apply -f infra/k8s/netpol-allow-app-from-labeled.yaml

netpol-test:
	@NS=ship SVC=app PORT=8080 bash -lc '\
	kubectl -n $$NS run np-client --restart=Never --image=nicolaka/netshoot -- /bin/sh -lc "apk add --no-cache curl >/dev/null 2>&1 || true; curl -sS --max-time 5 http://$$SVC.$$NS.svc.cluster.local:$$PORT/healthz || echo BLOCKED" | tee -a artifacts/netpol-test.txt; \
	kubectl -n $$NS delete pod np-client --now; \
	kubectl -n $$NS run np-allowed --restart=Never --image=nicolaka/netshoot -- /bin/sh -lc "sleep 3600" >/dev/null 2>&1 & sleep 2; \
	kubectl -n $$NS label pod np-allowed access=allowed --overwrite; \
	kubectl -n $$NS exec np-allowed -- /bin/sh -lc "apk add --no-cache curl >/dev/null 2>&1 || true; curl -sS --max-time 5 http://$$SVC.$$NS.svc.cluster.local:$$PORT/healthz" | tee -a artifacts/netpol-test.txt; \
	kubectl -n $$NS delete pod np-allowed --now; \
	echo "Wrote artifacts/netpol-test.txt"'

Checklist

  • Your Minikube profile has a CNI that enforces NetworkPolicy
  • default-deny applied for Ingress+Egress
  • Labeled allow-rule works (blocked → allowed)
  • artifacts/netpol-test.txt saved with both outcomes

Chapter 5. Checklist

Decide how you’ll enforce at admission and constrain at runtime.

Namespaces & Multitenancy

  • Namespace model: Per team/app/tenant? Is pod-security.kubernetes.io/enforce=restricted applied across namespaces?
  • Separation: Do build/test namespaces differ from runtime namespaces?

RBAC & Service Accounts

  • Least privilege: Do workloads run under dedicated SAs with namespace-scoped Roles only?
  • can-i evidence: Do you record kubectl auth can-i checks in artifacts?

Network Policies

  • Default deny: Is there a cluster-wide default deny for ingress/egress?
  • Allow rules: Have you explicitly allowed app ingress and DNS egress only where needed?

Admission Controls (Kyverno)

  • Deny bad: Which rules are enforced? (no privileged, drop ALL caps, runAsNonRoot, probes, seccomp)
  • Verify good: Are image signatures verified? Is there an org-registry allow-list?

Image Integrity

  • Cosign policy: Which public keys are trusted? How are they rotated?
  • Untrusted images: What’s the response when an image is denied (runbook, alerting)?

Secrets & Config

  • SOPS/age: Are Kubernetes Secrets encrypted in Git? Who can decrypt?
  • External secrets: Any plan to fetch secrets from KMS/secret manager?

Runtime Signals & WAF

  • Falco: Which rules are enabled? Who receives alerts, and where?
  • WAF: Where does WAF sit (ingress controller vs edge)? What attack classes do you block first?

In-Cluster Scanning

  • Trivy Operator: Will you enable it? Who triages CRD reports and how often?

Chapter 5. Encrypt Kubernetes Secrets in Git

Why this

You don’t need a cloud KMS to stop plaintext secrets in Git. SOPS + age lets you encrypt fields in YAML, commit safely, and decrypt only when applying on your machine.

1) Install SOPS and age (one-time)

  • macOS: brew install sops age
  • Windows (PowerShell): winget install -e --id Mozilla.SOPS and winget install -e --id FiloSottile.OpenSSH (age may be in package managers you use)
  • Linux (Debian/Ubuntu): sudo apt install -y sops age

If your platform uses different packages, install SOPS and age via your package manager of choice.

2) Generate an age key (keep local)

age-keygen -o .sops.agekey
echo 'export SOPS_AGE_KEY_FILE=.sops.agekey' >> .env.sops
source .env.sops

Do not commit .sops.agekey. Add it to .gitignore.

3) Define SOPS rules for Kubernetes Secrets

Create .sops.yaml at repo root:

# Encrypt Kubernetes Secret data/stringData by default
creation_rules:
  - path_regex: infra/k8s/.*\.secret\.ya?ml$
    encrypted_regex: '^(data|stringData)$'
    age: []
    # age recipient(s) will be added automatically from your key when you first encrypt

4) Create a sample Secret (unencrypted working copy)

Create infra/k8s/secret.secret.yaml:

apiVersion: v1
kind: Secret
metadata:
  name: demo-secret
  namespace: ship
type: Opaque
stringData:
  API_TOKEN: "replace-me"

5) Encrypt it in-place

sops -e -i infra/k8s/secret.secret.yaml
git add infra/k8s/secret.secret.yaml .sops.yaml
git commit -m "chore(secrets): add encrypted Secret via SOPS"

Open the file—you should see ciphertext, plus a sops: metadata block.

6) Apply to the cluster (decrypt on the fly)

sops -d infra/k8s/secret.secret.yaml | kubectl apply -f -
kubectl -n ship get secret demo-secret -o yaml | grep -A3 data:

You’ll see base64 fields at runtime (normal for K8s), but Git still holds ciphertext.

7) Audit evidence (prove it’s encrypted)

grep -n "sops:" -n infra/k8s/secret.secret.yaml > artifacts/sops-audit.txt
echo "Wrote artifacts/sops-audit.txt"

Makefile helpers (optional)

sops-keygen:
	@test -f .sops.agekey || age-keygen -o .sops.agekey
	@echo "export SOPS_AGE_KEY_FILE=.sops.agekey" > .env.sops
	@echo "Run: source .env.sops"

sops-encrypt:
	sops -e -i infra/k8s/secret.secret.yaml

sops-decrypt:
	sops -d infra/k8s/secret.secret.yaml > /tmp/secret.plain.yaml && \
	echo "Decrypted to /tmp/secret.plain.yaml (do not commit)"

k8s-apply-sops:
	sops -d infra/k8s/secret.secret.yaml | kubectl apply -f -

Add .sops.agekey to .gitignore:

.sops.agekey
.env.sops
/tmp/secret.plain.yaml

Checklist

  • .sops.agekey generated and not committed
  • .sops.yaml present with Kubernetes rules
  • infra/k8s/secret.secret.yaml shows ciphertext in Git
  • sops -d … | kubectl apply -f - works
  • artifacts/sops-audit.txt saved

Chapter 5. OPA and Conftest

Why this

Static linters (kube-linter / kube-score / Checkov) are great. OPA/Conftest lets you add your org’s rules that those tools don’t cover or you want to phrase differently, and run them before admission.

1) Repo layout

policy/ k8s.rego infra/k8s/

your Deployment/Service/etc.

2) Policy (copy/paste)

Create policy/k8s.rego with three practical rules:

package main

# Input is a list of Kubernetes resources (YAML files parsed by conftest)

deny[msg] {
  some i
  input[i].kind == "Deployment"
  some c
  containers := input[i].spec.template.spec.containers
  containers[c].image endswith ":latest"
  msg := sprintf("Deployment %q uses :latest tag in container %q", [input[i].metadata.name, containers[c].name])
}

deny[msg] {
  some i
  input[i].kind == "Deployment"
  some c
  containers := input[i].spec.template.spec.containers
  not containers[c].securityContext.runAsNonRoot
  msg := sprintf("Deployment %q container %q missing runAsNonRoot: true", [input[i].metadata.name, containers[c].name])
}

deny[msg] {
  some i
  input[i].kind == "Deployment"
  some c
  containers := input[i].spec.template.spec.containers
  not containers[c].resources.requests.cpu
  not containers[c].resources.requests.memory
  msg := sprintf("Deployment %q container %q missing CPU/Memory requests (and likely limits)", [input[i].metadata.name, containers[c].name])
}

You can extend this file over time (e.g., require readiness/liveness, forbid privileged, etc.).

3) Run Conftest (Dockerized, no local install)

From repo root:

mkdir -p artifacts
docker run --rm -v "$PWD":/work -w /work openpolicyagent/conftest:v0.56.0 \
  test infra/k8s -p policy | tee artifacts/conftest.txt || true
  • Exit code 0 = all good; non-zero = at least one deny fired.
  • Either way, the report is saved to ./artifacts/conftest.txt.

4) Makefile target (optional)

k8s-conftest: init
	docker run --rm -v $(PWD):/work -w /work openpolicyagent/conftest:v0.56.0 \
	  test infra/k8s -p policy | tee artifacts/conftest.txt || true

5) Integrate with your flow

  • Run make k8s-audit k8s-conftest before make cd.
  • Keep Conftest in WARN for Week 1–2; promote to BLOCK once noise is low.

Checklist

  • policy/k8s.rego committed with at least 3 rules
  • artifacts/conftest.txt produced and reviewed
  • Violations fixed (or exceptions recorded) before deploy

Chapter 5. Runtime Detection with Falco

Why this step

Admission policies reduce risky manifests, but runtime behavior can still go sideways. Falco watches syscalls and flags suspicious actions.

Install Falco

make falco-install
kubectl -n falco get pods

Add a simple rule (no interactive shells)

Ensure this file exists:

falco/rules.d/no-shell.yaml

(Already provided in the repo plan: flags /bin/sh, /bin/bash, /bin/zsh inside containers.)

Load custom rules following the Falco chart’s values—mount a ConfigMap with your rules.d/ contents. (You can keep this step optional for the first run.)

Generate an alert

Open a shell inside your app container:

kubectl -n ship exec -it deploy/app -- sh
exit

Tail logs:

make falco-logs

Export the last 10 minutes as evidence:

make falco-export
# -> artifacts/falco-last10m.log

Cleanup

helm -n falco uninstall falco
kubectl delete ns falco

Checklist

  • Falco installed; you saw logs from the DaemonSet
  • Entering a container shell produced an alert
  • artifacts/falco-last10m.log saved

Chapter 5. Trivy Operator

Why this step

You already produce local artifacts in ./artifacts/. The operator gives you a cluster view of risks as Kubernetes resources.

Install

Follow the operator’s installation instructions (via Helm or manifests). On a successful install you can query:

kubectl get ns        # confirm operator namespace
kubectl get vulnerabilityreports -A
kubectl get configauditreports -A

Evidence (optional)

Save a quick dump:

kubectl get vulnerabilityreports -A -o wide > artifacts/operator-vulnreports.txt
kubectl get configauditreports -A -o wide > artifacts/operator-configaudit.txt

Cleanup

Uninstall using the same method you installed with (helm uninstall or kubectl delete).


Checklist

  • Operator installed and CRDs visible
  • Export of reports saved under artifacts/ (optional)
  • Uninstall instructions verified

Chapter 5. WAF Quickstart

Why this

When a code fix needs a sprint, a reverse-proxy filter can block a whole class of bad inputs as a stopgap. We’ll use the community ingress-nginx controller that ships with Minikube and add a basic rule via annotations on your Ingress.

Note: These steps use the Minikube ingress addon. They don’t require separate ingress images or cloud ingress.

1) Enable the Ingress controller

minikube -p ship addons enable ingress
kubectl -n ingress-nginx get pods

2) Create an Ingress for your app

Assuming your Service is app in namespace ship on port 8080, create infra/k8s/ingress.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app
  namespace: ship
  annotations:
    # Turn on basic request filtering in the ingress (see next section for rule)
    nginx.ingress.kubernetes.io/server-snippet: |
      # Block very basic script injection in query/path
      if ($request_uri ~* "<script") { return 403; }
spec:
  ingressClassName: nginx
  rules:
    - host: app.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app
                port:
                  number: 8080

Apply it:

kubectl apply -f infra/k8s/ingress.yaml

We use server-snippet to add a minimal block rule at the NGINX layer. For a richer rule set, you can layer ModSecurity/CRS if your ingress build enables it; start with the simple rule to prove the workflow.

3) Get a local URL for the Ingress

minikube -p ship tunnel >/dev/null 2>&1 &
# /etc/hosts entry for convenience (macOS/Linux):
echo "127.0.0.1 app.local" | sudo tee -a /etc/hosts

Verify:

curl -i http://app.local/healthz

4) Prove the block (virtual patch)

Try a normal request and a malicious one; capture both results:

{
  echo "--- OK request $(date) ---"
  curl -sSi "http://app.local/?input=hello" | head -n 1

  echo "--- BLOCK attempt $(date) ---"
  curl -sSi "http://app.local/?input=<script>alert(1)</script>" | head -n 1
} | tee -a artifacts/waf-test.txt

Expected:

  • Normal request → HTTP/1.1 200 OK
  • Attack string → HTTP/1.1 403 Forbidden (blocked by the server snippet)

5) Optional: move the rule to a dedicated ConfigMap

If you prefer not to inline snippets in the Ingress, you can mount a global config via the ingress-nginx ConfigMap (cluster-wide) and reference it. For the local lab, the per-Ingress annotation is sufficient and keeps everything in your repo.

6) Clean up (optional)

# remove hosts entry if added
sudo sed -i.bak '/app\.local/d' /etc/hosts
# stop the tunnel process if left running in the background
pkill -f "minikube.*tunnel" || true

Makefile helper (optional)

waf-apply:
	kubectl apply -f infra/k8s/ingress.yaml
	@echo "Ingress applied. If needed, run: minikube -p ship tunnel"

waf-test:
	@bash -lc '\
	mkdir -p artifacts; \
	echo "--- OK request $$(
	  date) ---" >> artifacts/waf-test.txt; \
	curl -sSi "http://app.local/?input=hello" | head -n 1 >> artifacts/waf-test.txt || true; \
	echo "--- BLOCK attempt $$(
	  date) ---" >> artifacts/waf-test.txt; \
	curl -sSi "http://app.local/?input=<script>alert(1)</script>" | head -n 1 >> artifacts/waf-test.txt || true; \
	echo "Wrote artifacts/waf-test.txt"'

Checklist

  • Ingress controller running (ingress-nginx Pods are Ready)
  • Ingress created for your Service; app.local resolves
  • Normal traffic returns 200; attack string returns 403
  • artifacts/waf-test.txt saved with both outcomes

← Back to the blog