kubestronaut-sim/cks/bank_supply.py
vumas3r f3d0e536b6 Exam simulator for CKA, CKAD and CKS with per-task hosts and a remote desktop
kubestronaut-sim runs each exam task on a host of its own - a kind (kubeadm)
cluster in rootless podman, or a QEMU/KVM Ubuntu VM for the CKS tasks that
need their own kernel (Falco, AppArmor) - and puts a browser desktop in front
of them, the way killer.sh and sailor.sh do.

- ksim, sim/: the engine (sessions, drills, study mode, grading, coverage,
  audit), host and VM lifecycle, the desktop
- images/: exam host images (common, CKS variant with its tools, VM guest)
- cka/ (131 tasks), ckad/ (105), cks/ (110): task banks, curricula with
  study notes, reference solutions and scripted solutions

Every domain of every track has at least 17 pool tasks. `./ksim audit` proves
a task three ways: it seeds cleanly on its own host, scores nothing before
anyone has worked on it, and reaches full marks when its scripted solution is
run on the host as `candidate`. All 346 tasks pass (CKA audited 2026-10-13,
CKAD and CKS 2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-13 16:29:33 -06:00

1110 lines
56 KiB
Python

"""CKS tasks 71-86: Supply Chain Security - the pool tasks that take the domain from 1 to 17.
5.1 Base images 71 harden a Dockerfile and build it 72 a multi-stage build
73 review a Dockerfile and a manifest
5.2 Supply chain, 74 SBOMs in two formats 75 which workload ships that package?
SBOM 76 scan an SBOM for vulnerabilities 77 run what was approved, by digest
78 SBOMs with bom
5.3 Registries and 79 an ImagePolicyWebhook that decides 80 no image without a pinned tag
signatures 81 credentials for a private registry 82 apply only what is signed
5.4 Static analysis 83 kubesec 84 KubeLinter
85 images with critical vulnerabilities 86 trivy config
CKS hosts carry trivy, kubesec, kube-linter, bom and cosign, and podman for the builds. Images are
built and run on the host for real; scanners are run again by the grader on what the candidate
left behind, with the same vulnerability database (fetched when the task is seeded and frozen for
the session). 79 runs a real webhook backend that refuses untagged and `latest` images.
Every task has a scripted solution in solve.py.
"""
from cks.common import API, C, PY, NS, ns_pick, name_pick
DOMS = ["acme", "globex", "initech", "umbrella", "hooli", "stark"]
# img IMAGE GO-TEMPLATE: a property of an image in the host's store ("" if there is no such image)
IMG = r"""
img() { podman image inspect "$1" --format "$2" 2>/dev/null | tr -d '\r'; }
"""
TRIVY = r"""
# tv ARGS..: trivy on the host, as root. Its cache directory is shared with `candidate`
# (/etc/trivy.yaml), so what root creates there is opened up again afterwards.
tv() { nx "$CP" "TRIVY_CONFIG=/etc/trivy.yaml trivy $*; rc=\$?; chmod -R a+rwX /var/lib/trivy 2>/dev/null; exit \$rc" </dev/null; }
# trivy_db: the vulnerability database on the host - fetched once, then frozen, so that the
# candidate's scans and the grader's read the same data for as long as the host lives
trivy_db() {
tv image --download-db-only -q >/dev/null 2>&1 || return 1
nx "$CP" "grep -q 'skip-update' /etc/trivy.yaml || printf 'db:\n skip-update: true\n' >> /etc/trivy.yaml" </dev/null
}
# count KIND < trivy JSON report: how many findings of KIND (Vulnerabilities, Misconfigurations) it holds
count() { python3 -c '
import json, sys
try:
r = json.load(sys.stdin).get("Results", [])
except Exception:
sys.exit(1)
print(sum(len(x.get(sys.argv[1]) or []) for x in r))' "$1"; }
# crit IMAGE: how many CRITICAL vulnerabilities trivy finds in IMAGE ("" when the scan fails)
crit() { tv image -q --severity CRITICAL --format json "$1" 2>/dev/null | count Vulnerabilities; }
"""
# same LIST-FILE EXPECTED-FILE: the candidate's list names exactly the expected items
SAME = r"""
same() { [ -s "$1" ] && diff <(tr -d ' \r' < "$1" | sed 's#.*/##' | grep . | sort -u) <(sort -u "$2") >/dev/null; }
"""
# would NS IMAGE [INIT-IMAGE]: the API server would admit a Pod with these images (server-side dry run)
WOULD = r"""
would() {
local init=""; [ -n "${3:-}" ] && init="initContainers: [{name: init, image: \"$3\"}]"
$K apply --dry-run=server -f - >/dev/null 2>&1 <<EOF
apiVersion: v1
kind: Pod
metadata: {name: ksim-admission-probe, namespace: $1}
spec:
$init
containers: [{name: c, image: "$2"}]
EOF
}
# refused NS IMAGE [INIT-IMAGE]: it would not - given the moment a new policy needs to take effect
refused() { local i; for i in 1 2 3 4 5 6; do would "$@" || return 0; sleep 3; done; return 1; }
"""
def _tok(r, n, alphabet="abcdefghjkmnpqrstuvwxyz23456789"):
return "".join(r.choice(alphabet) for _ in range(n))
def _names(r):
n = r.sample(["web", "cache", "proxy", "tools", "api", "queue"], 4)
return {"ns": ns_pick(r, "fleet"), "d0": n[0], "d1": n[1], "d2": n[2], "d3": n[3]}
QUESTIONS = [
# ================================================================ 5.1 Base images ====
{
"id": 71, "domain": "SUPPLY", "title": "Harden a Dockerfile and build it",
"params": lambda r: {"img": name_pick(r, "inventory"), "tok": _tok(r, 16)},
"task": """
`{WS}/q71/app/` holds a small service (`app.sh`) and a Dockerfile that packs it into an image five
hundred megabytes too large, with a secret inside, running as root.
Rewrite the Dockerfile and build the image as `localhost/%{img}:hardened` (on this host, with
`podman` or `docker`):
1. Base image `alpine:3.20` — small, and pinned to a version.
2. Nothing installed that `app.sh` does not need (it needs a shell, nothing more).
3. No `API_TOKEN` in the image — it will be supplied when the container starts.
4. The container runs as the unprivileged user id `10001`.
5. `podman run --rm localhost/%{img}:hardened` still prints the service's line.
""",
"setup": r"""
d="$WS/q71/app"; rm -rf "$WS/q71"; mkdir -p "$d"
cat > "$d/app.sh" <<'EOF'
#!/bin/sh
echo "inventory-service ok (uid $(id -u))"
EOF
chmod 755 "$d/app.sh"
cat > "$d/Dockerfile" <<'EOF'
FROM ubuntu:latest
ENV API_TOKEN=tok-%{tok}
RUN apt-get update && apt-get install -y curl vim git build-essential
COPY app.sh /app/app.sh
RUN chmod 755 /app/app.sh
CMD ["/app/app.sh"]
EOF
podman rmi -f localhost/%{img}:hardened >/dev/null 2>&1; true
""",
"checks": [
C("localhost/%{img}:hardened exists, is built from alpine:3.20 and is smaller than 30 MB", 3, IMG + r"""
s=$(img localhost/%{img}:hardened '{{.Size}}'); [ -n "$s" ] && [ "$s" -lt 30000000 ] || exit 1
grep -Eiq '^FROM +(docker\.io/)?(library/)?alpine:3\.20( |$)' "$WS/q71/app/Dockerfile" && ! grep -Eiq 'apt-get|apk +add' "$WS/q71/app/Dockerfile" """),
C("it runs as user id 10001", 2, IMG + r"""
img localhost/%{img}:hardened '{{.Config.User}}' | grep -Eq '^10001(:|$)' && eq "$(podman run --rm localhost/%{img}:hardened id -u 2>/dev/null | tr -d '\r')" 10001"""),
C("the token is in no layer, no variable and no file of the build", 2, IMG + r"""
[ -n "$(img localhost/%{img}:hardened '{{.Id}}')" ] || exit 1
! img localhost/%{img}:hardened '{{.Config.Env}}' | grep -q API_TOKEN && ! podman history --no-trunc localhost/%{img}:hardened 2>/dev/null | grep -q -e 'tok-%{tok}' -e API_TOKEN \
&& ! grep -rq 'tok-%{tok}' "$WS/q71/app/" """),
C("the container still prints the service's line", 3, r"""
podman run --rm localhost/%{img}:hardened 2>/dev/null | grep -q 'inventory-service ok (uid 10001)'"""),
],
"solution": """
cd {WS}/q71/app
cat > Dockerfile <<EOF
FROM alpine:3.20
COPY app.sh /app/app.sh
RUN chmod 755 /app/app.sh
USER 10001
CMD ["/app/app.sh"]
EOF
podman build -t localhost/%{img}:hardened .
podman run --rm localhost/%{img}:hardened # inventory-service ok (uid 10001)
podman images | grep %{img}; podman history localhost/%{img}:hardened
# `latest` is whatever was pushed last: pin a version (or a digest). A value in ENV - or ARG, or a
# file that a later RUN deletes - stays in the image's layers and history for everyone who can
# pull it: secrets belong to the runtime (a Secret, mounted or as a variable). USER after the last
# step that needs root, and by number: `runAsNonRoot` cannot verify a user given by name.
""",
},
{
"id": 72, "domain": "SUPPLY", "title": "A multi-stage build", "difficulty": "hard",
"params": lambda r: {"img": name_pick(r, "render"), "ver": "v%d.%d" % (r.randint(1, 4), r.randint(0, 9))},
"task": """
`{WS}/q72/src/` holds a small Go program and a Dockerfile that builds and runs it in the same
image — the whole Go toolchain ships to production with it.
Turn it into a **multi-stage build** and build the image as `localhost/%{img}:slim`:
1. Build stage: `golang:1.23-alpine`; compile a static binary (`CGO_ENABLED=0`).
2. Final stage: `scratch` or `alpine:3.20`, containing the binary and nothing of the toolchain.
3. The container runs as user id `65532`.
4. `podman run --rm localhost/%{img}:slim` prints the program's line, as before.
The final image must be smaller than 25 MB.
""",
"setup": r"""
d="$WS/q72/src"; rm -rf "$WS/q72"; mkdir -p "$d"
cat > "$d/main.go" <<'EOF'
package main
import "fmt"
func main() {
fmt.Println("render-worker %{ver} ready")
}
EOF
cat > "$d/Dockerfile" <<'EOF'
FROM golang:1.23
WORKDIR /src
COPY main.go .
RUN go build -o /usr/local/bin/render-worker main.go
CMD ["render-worker"]
EOF
podman rmi -f localhost/%{img}:slim >/dev/null 2>&1; true
""",
"checks": [
C("localhost/%{img}:slim exists and is smaller than 25 MB", 3, IMG + r"""
s=$(img localhost/%{img}:slim '{{.Size}}'); [ -n "$s" ] && [ "$s" -lt 25000000 ]"""),
C("the Dockerfile has a build stage from golang:1.23-alpine and a final stage from scratch or alpine:3.20", 2, IMG + r"""
[ -n "$(img localhost/%{img}:slim '{{.Id}}')" ] || exit 1
f="$WS/q72/src/Dockerfile"
[ "$(grep -Eic '^FROM ' "$f")" -ge 2 ] && grep -Eiq '^FROM +(docker\.io/)?(library/)?golang:1\.23-alpine +AS +' "$f" \
&& grep -Ei '^FROM ' "$f" | tail -1 | grep -Eiq '^FROM +((docker\.io/)?(library/)?alpine:3\.20|scratch)( |$)' && grep -Eq -- '--from=' "$f" """),
C("it runs as user id 65532 and has no Go toolchain", 2, IMG + r"""
img localhost/%{img}:slim '{{.Config.User}}' | grep -Eq '^65532(:|$)' || exit 1
! podman run --rm --entrypoint /usr/local/go/bin/go localhost/%{img}:slim version >/dev/null 2>&1"""),
C("the container prints the program's line", 3, r"""podman run --rm localhost/%{img}:slim 2>/dev/null | grep -q 'render-worker %{ver} ready'"""),
],
"solution": """
cd {WS}/q72/src
cat > Dockerfile <<EOF
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go build -o /render-worker main.go
FROM scratch
COPY --from=build /render-worker /render-worker
USER 65532
ENTRYPOINT ["/render-worker"]
EOF
podman build -t localhost/%{img}:slim .
podman run --rm localhost/%{img}:slim # render-worker %{ver} ready
podman images | grep -E '%{img}|golang' # a couple of MB against several hundred
# Only the last stage becomes the image; earlier stages exist to be copied from. A static binary
# (CGO_ENABLED=0) needs no libc, so `scratch` - an empty image - is enough: no shell, no package
# manager, nothing to exploit or to patch. scratch has no /etc/passwd, hence USER by number.
""",
},
{
"id": 73, "domain": "SUPPLY", "title": "Review a Dockerfile and a manifest",
"params": lambda r: {"ns": ns_pick(r, "store"), "pw": "pw-" + _tok(r, 12)},
"task": """
Before it goes to the pipeline, review `{WS}/q73/Dockerfile` and `{WS}/q73/deployment.yaml` and
fix them **in place**. Do not build or apply anything.
Dockerfile:
1. The base image has no version: pin it to `nginx:1.27`.
2. A password is baked into the image: remove it.
3. The image ends up running as root: its last `USER` must be `nginx`.
deployment.yaml:
4. The container must not be privileged.
5. It must not run as root: `runAsNonRoot: true`, user id `101`.
6. `DB_PASSWORD` must come from Secret `storefront-db`, key `password`, not from a literal value.
Change nothing else: the Deployment's name, image and replicas stay.
""",
"setup": NS + r"""
mkns %{ns}; d="$WS/q73"; rm -rf "$d"; mkdir -p "$d"
cat > "$d/Dockerfile" <<'EOF'
FROM nginx
ENV DB_PASSWORD=%{pw}
COPY site/ /usr/share/nginx/html/
RUN chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/run
USER nginx
EXPOSE 8080
USER root
EOF
cat > "$d/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: storefront
namespace: %{ns}
spec:
replicas: 2
selector:
matchLabels:
app: storefront
template:
metadata:
labels:
app: storefront
spec:
containers:
- name: web
image: registry.internal/storefront:2.4.1
env:
- name: DB_HOST
value: db.%{ns}.svc
- name: DB_PASSWORD
value: %{pw}
securityContext:
privileged: true
runAsUser: 0
EOF
""",
"checks": [
C("Dockerfile: base pinned to nginx:1.27, no password, last USER is nginx", 4, r"""
f="$WS/q73/Dockerfile"
grep -Eiq '^FROM +(docker\.io/)?(library/)?nginx:1\.27( |$)' "$f" && ! grep -q -e 'DB_PASSWORD' -e '%{pw}' "$f" \
&& [ "$(grep -Ei '^USER ' "$f" | tail -1 | awk '{print $2}')" = nginx ] && grep -q '^COPY site/' "$f" """),
C("deployment.yaml: not privileged, runAsNonRoot with user id 101", 3, r"""
$K create --dry-run=client -o json -f "$WS/q73/deployment.yaml" 2>/dev/null | python3 -c '
import json, sys
o = json.load(sys.stdin); t = o["spec"]["template"]["spec"]; c = t["containers"][0]
cs = c.get("securityContext", {}); ps = t.get("securityContext", {})
ok = not cs.get("privileged") and cs.get("runAsNonRoot", ps.get("runAsNonRoot")) is True and cs.get("runAsUser", ps.get("runAsUser")) == 101
sys.exit(0 if ok and o["metadata"]["name"] == "storefront" and c["image"] == "registry.internal/storefront:2.4.1" and o["spec"]["replicas"] == 2 else 1)'"""),
C("DB_PASSWORD comes from Secret storefront-db, key password; the literal is gone from both files", 3, r"""
! grep -rq '%{pw}' "$WS/q73/" || exit 1
$K create --dry-run=client -o json -f "$WS/q73/deployment.yaml" 2>/dev/null | python3 -c '
import json, sys
e = {x["name"]: x for x in json.load(sys.stdin)["spec"]["template"]["spec"]["containers"][0].get("env", [])}
r = e.get("DB_PASSWORD", {}).get("valueFrom", {}).get("secretKeyRef", {})
sys.exit(0 if r.get("name") == "storefront-db" and r.get("key") == "password" and e.get("DB_HOST", {}).get("value") else 1)'"""),
],
"solution": """
cd {WS}/q73
vi Dockerfile
# FROM nginx:1.27
# (delete the ENV DB_PASSWORD line)
# (delete the last line, USER root - the USER nginx above it then stands)
vi deployment.yaml
# env:
# - name: DB_PASSWORD
# valueFrom:
# secretKeyRef: {name: storefront-db, key: password}
# securityContext:
# runAsNonRoot: true
# runAsUser: 101
k create --dry-run=client -f deployment.yaml # still valid YAML?
# In a Dockerfile the LAST USER decides who the container runs as - an earlier `USER nginx`
# followed by `USER root` is root. The same goes for a review of a manifest: read the whole
# securityContext, on the Pod and on every container; the container's setting wins.
""",
},
# ======================================================= 5.2 Supply chain and SBOM ====
{
"id": 74, "domain": "SUPPLY", "title": "SBOMs in two formats",
"task": """
Produce software bills of materials with `trivy` on `{CP}`:
1. An SBOM of image `nginx:1.27-alpine` in **SPDX JSON** format: `{WS}/q74/nginx.spdx.json`.
2. An SBOM of image `redis:7.2-alpine` in **CycloneDX** (JSON) format: `{WS}/q74/redis.cdx.json`.
3. From the first one: write the version of package `busybox` in the nginx image to
`{WS}/q74/busybox.txt`.
""",
"setup": r"""rm -rf "$WS/q74"; mkdir -p "$WS/q74" """,
"checks": [
C("nginx.spdx.json is an SPDX document of the nginx image", 4, r"""
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
sys.exit(0 if str(d.get("spdxVersion", "")).startswith("SPDX-") and "nginx" in d.get("name", "") and len(d.get("packages", [])) > 10 else 1)' "$WS/q74/nginx.spdx.json" 2>/dev/null"""),
C("redis.cdx.json is a CycloneDX document of the redis image", 4, r"""
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
sys.exit(0 if d.get("bomFormat") == "CycloneDX" and "redis" in d.get("metadata", {}).get("component", {}).get("name", "") and len(d.get("components", [])) > 10 else 1)' "$WS/q74/redis.cdx.json" 2>/dev/null"""),
C("busybox.txt holds the version of package busybox from the SPDX document", 2, r"""
v=$(python3 -c '
import json, sys
print(next(p["versionInfo"] for p in json.load(open(sys.argv[1]))["packages"] if p.get("name") == "busybox"))' "$WS/q74/nginx.spdx.json" 2>/dev/null)
[ -n "$v" ] && eq "$(tr -d ' \n\r' < "$WS/q74/busybox.txt" 2>/dev/null)" "$v" """),
],
"solution": """
cd {WS}/q74
trivy image --format spdx-json --output nginx.spdx.json nginx:1.27-alpine
trivy image --format cyclonedx --output redis.cdx.json redis:7.2-alpine
jq -r '.packages[] | select(.name == "busybox") | .versionInfo' nginx.spdx.json | tee busybox.txt
jq -r '.components[] | .name + " " + .version' redis.cdx.json | head
# Without a --format for an SBOM, `trivy image` scans for vulnerabilities instead. The two formats
# keep the same facts in different places: SPDX in .packages[] (versionInfo), CycloneDX in
# .components[] (version). Trivy's documentation is not on the exam's allowed list:
# trivy image --help | grep -A4 -- '--format'
""",
},
{
"id": 75, "domain": "SUPPLY", "title": "Which workload ships that package?",
"params": _names,
"task": """
A critical bug was announced in the Debian package **libssl1.1**, version **1.1.1d-0+deb10u3**.
Namespace `%{ns}` runs four Deployments: `%{d1}`, `%{d0}`, `%{d3}` and `%{d2}`.
1. Find the Deployment whose image contains that package in exactly that version, and write its
name to `{WS}/q75/affected.txt`.
2. Save a CycloneDX SBOM of that image to `{WS}/q75/sbom.json`, as evidence.
3. Scale the affected Deployment to 0 replicas. Leave the others alone.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q75"; mkdir -p "$WS/q75"
$K -n %{ns} create deploy %{d0} --image=nginx:1.19.0 >/dev/null
$K -n %{ns} create deploy %{d1} --image=redis:7.2-alpine >/dev/null
$K -n %{ns} create deploy %{d2} --image=nginx:1.27-alpine >/dev/null
$K -n %{ns} create deploy %{d3} --image=busybox:1.36 -- sleep 86400 >/dev/null
for d in %{d0} %{d1} %{d2} %{d3}; do $K -n %{ns} rollout status deploy $d --timeout=280s >/dev/null || exit 1; done
""",
"checks": [
C("affected.txt names the Deployment", 4, r"""eq "$(tr -d ' \n\r' < "$WS/q75/affected.txt" 2>/dev/null | sed 's#.*/##')" %{d0}"""),
C("sbom.json is a CycloneDX SBOM that lists libssl1.1 1.1.1d-0+deb10u3", 3, r"""
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
sys.exit(0 if d.get("bomFormat") == "CycloneDX" and any(c.get("name") == "libssl1.1" and c.get("version") == "1.1.1d-0+deb10u3" for c in d.get("components", [])) else 1)' "$WS/q75/sbom.json" 2>/dev/null"""),
C("that Deployment is scaled to 0, the other three still have their replica", 3, r"""
eq "$(j -n %{ns} deploy %{d0} '{.spec.replicas}')" 0 || exit 1
for d in %{d1} %{d2} %{d3}; do eq "$(j -n %{ns} deploy $d '{.spec.replicas}')" 1 || exit 1; done"""),
],
"solution": """
k -n %{ns} get deploy -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image
cd {WS}/q75
for i in nginx:1.19.0 redis:7.2-alpine nginx:1.27-alpine busybox:1.36; do
echo "== $i"; trivy image -q --format cyclonedx $i | jq -r '.components[] | select(.name == "libssl1.1") | .name + " " + .version'
done # only nginx:1.19.0 answers: libssl1.1 1.1.1d-0+deb10u3
trivy image -q --format cyclonedx --output sbom.json nginx:1.19.0
echo %{d0} > affected.txt
k -n %{ns} scale deploy %{d0} --replicas=0
# This is what an SBOM is for: answering "where do we run package X in version Y" by searching
# documents instead of pulling and unpacking every image. In a pipeline the SBOM is produced at
# build time and stored next to the image.
""",
},
{
"id": 76, "domain": "SUPPLY", "title": "Scan an SBOM for vulnerabilities",
"task": """
The build pipeline stores an SBOM with every image. `{WS}/q76/app.cdx.json` is the one of the
image that runs in production. Scan **the SBOM** (not an image) with `trivy` on `{CP}`:
1. Write the number of **CRITICAL** findings trivy reports for it (`CRITICAL: N` in its summary)
to `{WS}/q76/critical-count.txt`.
2. Write the CVE ids of the CRITICAL findings in package `libssl1.1` to `{WS}/q76/libssl.txt`, one
per line.
3. Save the report of the CRITICAL findings in JSON format to `{WS}/q76/report.json`.
""",
"setup": TRIVY + r"""
rm -rf "$WS/q76"; mkdir -p "$WS/q76"
trivy_db || { echo "trivy could not fetch its vulnerability database" >&2; exit 1; }
tv image -q --format cyclonedx --output /opt/course/q76/app.cdx.json nginx:1.19.0 >/dev/null 2>&1
[ -s "$WS/q76/app.cdx.json" ] || { echo "no SBOM was produced" >&2; exit 1; }
tv sbom -q --severity CRITICAL --format json /opt/course/q76/app.cdx.json 2>/dev/null > "$PRIV/q76.json"
python3 -c '
import json, sys
v = [x for r in json.load(open(sys.argv[1])).get("Results", []) for x in (r.get("Vulnerabilities") or [])]
ssl = sorted({x["VulnerabilityID"] for x in v if x.get("PkgName") == "libssl1.1"})
open(sys.argv[2], "w").write(str(len(v)) + "\n"); open(sys.argv[3], "w").write("\n".join(ssl) + "\n")
sys.exit(0 if v and ssl else 1)' "$PRIV/q76.json" "$PRIV/q76.count" "$PRIV/q76.ssl"
""",
"checks": [
C("critical-count.txt holds the number of CRITICAL findings", 4, r"""eq "$(tr -dc '0-9' < "$WS/q76/critical-count.txt" 2>/dev/null)" "$(tr -dc '0-9' < "$PRIV/q76.count")" """),
C("libssl.txt lists the CRITICAL CVEs of libssl1.1", 4, r"""
[ -s "$WS/q76/libssl.txt" ] && diff <(tr -d ' \r' < "$WS/q76/libssl.txt" | grep . | sort -u) <(sort -u "$PRIV/q76.ssl") >/dev/null"""),
C("report.json is trivy's JSON report with the CRITICAL findings only", 2, r"""
python3 -c '
import json, sys
v = [x for r in json.load(open(sys.argv[1])).get("Results", []) for x in (r.get("Vulnerabilities") or [])]
sys.exit(0 if v and all(x.get("Severity") == "CRITICAL" for x in v) else 1)' "$WS/q76/report.json" 2>/dev/null"""),
],
"solution": """
cd {WS}/q76
trivy sbom --severity CRITICAL app.cdx.json | head -8 # Total: N (CRITICAL: N)
trivy sbom -q --severity CRITICAL --format json --output report.json app.cdx.json
jq '[.Results[].Vulnerabilities[]?] | length' report.json > critical-count.txt
jq -r '.Results[].Vulnerabilities[]? | select(.PkgName == "libssl1.1") | .VulnerabilityID' report.json | sort -u > libssl.txt
# `trivy sbom` matches the packages listed in the document against the vulnerability database -
# no image is pulled. An SBOM made last year can be scanned against today's database, which is
# how you learn that something already deployed has become vulnerable.
""",
},
{
"id": 77, "domain": "SUPPLY", "title": "Run what was approved, by digest",
"params": lambda r: {"ns": ns_pick(r, "prod"), "bad": r.choice(["frontend", "sessions", "docs"])},
"task": """
Only images that the security team has approved may run in Namespace `%{ns}`. The approved images
are listed by digest in `{WS}/q77/approved-digests.txt` — a tag can be moved to another image, a
digest cannot. Three Deployments run there: `frontend`, `sessions` and `docs`.
1. For each Deployment, find the digest of the image its Pod is **actually running**, and write
one line per Deployment to `{WS}/q77/running.txt`: `<deployment> <sha256:...>`.
2. Scale every Deployment whose digest is not on the approved list to 0 replicas.
3. Pin the approved Deployments: change their image reference from the tag to the approved
digest (`<repository>@sha256:...`). They must stay available.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q77"; mkdir -p "$WS/q77"
$K -n %{ns} create deploy frontend --image=nginx:1.27-alpine >/dev/null
$K -n %{ns} create deploy sessions --image=redis:7.2-alpine >/dev/null
$K -n %{ns} create deploy docs --image=httpd:2.4-alpine >/dev/null
: > "$PRIV/q77.running"; : > "$WS/q77/approved-digests.txt"
for d in frontend sessions docs; do
$K -n %{ns} rollout status deploy $d --timeout=280s >/dev/null || exit 1
dg=$($K -n %{ns} get pod -l app=$d -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' | sed 's/.*@//'); [ "${dg#sha256:}" != "$dg" ] || exit 1
echo "$d $dg" >> "$PRIV/q77.running"
[ $d = %{bad} ] || echo "$dg" >> "$WS/q77/approved-digests.txt"
done
echo "sha256:$(echo %{ns}-decoy | sha256sum | cut -d' ' -f1)" >> "$WS/q77/approved-digests.txt"
sort -o "$WS/q77/approved-digests.txt" "$WS/q77/approved-digests.txt"
""",
"checks": [
C("running.txt has the digest each Deployment's Pod was running", 3, r"""
[ -s "$WS/q77/running.txt" ] && diff <(tr -s ' \t' ' ' < "$WS/q77/running.txt" | sed 's/ *$//; s/\r//' | grep . | sort) <(sort "$PRIV/q77.running") >/dev/null"""),
C("%{bad}, whose image is not approved, is scaled to 0", 3, r"""
eq "$(j -n %{ns} deploy %{bad} '{.spec.replicas}')" 0 || exit 1
for d in frontend sessions docs; do [ $d = %{bad} ] || [ "$(j -n %{ns} deploy $d '{.spec.replicas}')" = 1 ] || exit 1; done"""),
C("the approved Deployments are pinned to their digest and available", 4, r"""
while read -r d dg; do
[ $d = %{bad} ] && continue
j -n %{ns} deploy $d '{.spec.template.spec.containers[0].image}' | grep -q "@$dg$" || exit 1
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy $d '{.status.updatedReplicas}')" = 1 ] && [ "$(j -n %{ns} deploy $d '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
[ "$(j -n %{ns} deploy $d '{.status.availableReplicas}')" = 1 ] || exit 1
done < "$PRIV/q77.running" """),
],
"solution": """
cd {WS}/q77
for d in frontend sessions docs; do
echo "$d $(k -n %{ns} get pod -l app=$d -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' | sed 's/.*@//')"
done | tee running.txt
grep -v -F -f approved-digests.txt running.txt # the line that is NOT approved: %{bad}
k -n %{ns} scale deploy %{bad} --replicas=0
# for each approved one: k -n %{ns} set image deploy/NAME '*=REPOSITORY@sha256:...'
k -n %{ns} get deploy -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image
# spec...image is what was asked for; status.containerStatuses[].imageID is what the node pulled.
# With a tag the two can drift apart overnight; with `repository@sha256:...` the kubelet runs that
# exact content or nothing.
""",
},
{
"id": 78, "domain": "SUPPLY", "title": "SBOMs with bom",
"task": """
The release process of this team uses `bom`, the SBOM tool of the Kubernetes project. On `{CP}`:
1. Generate an SPDX SBOM for the image archive `{WS}/q78/app.tar`: `{WS}/q78/image.spdx`.
2. Generate an SPDX SBOM for the source directory `{WS}/q78/src/`: `{WS}/q78/src.spdx`.
3. Save the outline `bom` draws of the **image** SBOM (`bom document outline`) to
`{WS}/q78/outline.txt`.
`bom generate --help` lists the options.
""",
"setup": r"""
d="$WS/q78"; rm -rf "$d"; mkdir -p "$d/src"
printf 'print("billing export")\n' > "$d/src/export.py"; printf 'requests==2.32.3\n' > "$d/src/requirements.txt"; printf '# billing-export\n' > "$d/src/README.md"
podman pull -q docker.io/library/busybox:1.36 >/dev/null 2>&1
podman save -o "$WS/q78/app.tar" docker.io/library/busybox:1.36 >/dev/null 2>&1
nx "$CP" 'test -s /opt/course/q78/app.tar && chmod 644 /opt/course/q78/app.tar' </dev/null
""",
"checks": [
C("image.spdx is an SPDX document that describes the busybox image", 4, r"""
grep -q 'SPDX-2' "$WS/q78/image.spdx" 2>/dev/null && grep -qi 'busybox' "$WS/q78/image.spdx" && grep -q 'bom' "$WS/q78/image.spdx" """),
C("src.spdx is an SPDX document that lists the files of src/", 3, r"""
grep -q 'SPDX-2' "$WS/q78/src.spdx" 2>/dev/null && grep -q 'export.py' "$WS/q78/src.spdx" && grep -q 'requirements.txt' "$WS/q78/src.spdx" """),
C("outline.txt is bom's outline of the image SBOM", 3, r"""
grep -q 'SPDX Document' "$WS/q78/outline.txt" 2>/dev/null && grep -qi 'busybox' "$WS/q78/outline.txt" """),
],
"solution": """
cd {WS}/q78
bom generate --image-archive app.tar --output image.spdx
bom generate --dirs src --output src.spdx
bom document outline image.spdx | tee outline.txt
# bom writes SPDX: every file and package with its checksums and licence, and how they relate
# (the image DESCRIBES its layers, a layer CONTAINS packages). --image NAME reads from a
# registry, --image-archive from a `docker save` tarball, --dirs from a source tree.
""",
},
# ================================================= 5.3 Registries and signatures ====
{
"id": 79, "domain": "SUPPLY", "title": "An ImagePolicyWebhook that decides", "difficulty": "hard",
"task": """
An image policy service runs on `{CP}` at `https://localhost:1323/image_policy`. It refuses every
image without a tag and every image tagged `latest`. Nothing asks it yet.
The configuration for the API server's **ImagePolicyWebhook** admission plugin was started in
`/etc/kubernetes/policywebhook/` and never finished:
1. `admission_config.yaml`: Pods must be refused when the policy service cannot be reached
(`defaultAllow`).
2. `kubeconf`: the `server` of the cluster entry must be the address of the policy service.
3. Enable the plugin on the API server and point it at `admission_config.yaml`
(`--admission-control-config-file`). The directory has to be visible inside the static Pod.
Keep the admission plugins that are already enabled.
4. When the API server is back: try `kubectl run test --image=nginx:latest` and save the error
message to `{WS}/q79/denied.txt`.
""",
"setup": API + r"""
rm -rf "$WS/q79"; mkdir -p "$WS/q79"
nx "$CP" 'set -e; d=/etc/kubernetes/policywebhook; rm -rf $d; mkdir -p $d; cd $d
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -keyout webhook-key.pem -out webhook-cert.pem -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 2>/dev/null
cat > admission_config.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: ImagePolicyWebhook
configuration:
imagePolicy:
kubeConfigFile: /etc/kubernetes/policywebhook/kubeconf
allowTTL: 1
denyTTL: 1
retryBackoff: 500
defaultAllow: true
EOF
cat > kubeconf <<EOF
apiVersion: v1
kind: Config
clusters:
- name: image-policy
cluster:
certificate-authority: /etc/kubernetes/policywebhook/webhook-cert.pem
server: https://CHANGE-ME/image_policy
contexts:
- name: image-policy
context:
cluster: image-policy
user: api-server
current-context: image-policy
users:
- name: api-server
user:
client-certificate: /etc/kubernetes/pki/apiserver.crt
client-key: /etc/kubernetes/pki/apiserver.key
EOF
cat > /etc/kubernetes/manifests/image-policy-service.yaml <<EOF
apiVersion: v1
kind: Pod
metadata: {name: image-policy-service, namespace: kube-system}
spec:
hostNetwork: true
containers:
- name: bouncer
image: docker.io/flavio/kube-image-bouncer:latest
imagePullPolicy: IfNotPresent
securityContext: {runAsUser: 0}
args: ["--cert", "/certs/webhook-cert.pem", "--key", "/certs/webhook-key.pem"]
volumeMounts: [{name: certs, mountPath: /certs, readOnly: true}]
volumes: [{name: certs, hostPath: {path: /etc/kubernetes/policywebhook}}]
EOF' </dev/null || exit 1
for i in $(seq 1 60); do nx "$CP" 'curl -sk -m 3 -o /dev/null https://localhost:1323/image_policy' </dev/null && exit 0; sleep 3; done
echo "the image policy service did not start" >&2; exit 1
""",
"checks": [
C("defaultAllow is false, and kubeconf points at the policy service", 2, r"""
d=/etc/kubernetes/policywebhook
eq "$(nx "$CP" "yq '.plugins[0].configuration.imagePolicy.defaultAllow' $d/admission_config.yaml" </dev/null)" false \
&& eq "$(nx "$CP" "yq '.clusters[0].cluster.server' $d/kubeconf" </dev/null)" https://localhost:1323/image_policy"""),
C("the API server runs with ImagePolicyWebhook (and NodeRestriction) and the configuration file", 3, API + r"""
runs kube-apiserver '--enable-admission-plugins=[^ ]*ImagePolicyWebhook' && runs kube-apiserver '--enable-admission-plugins=[^ ]*NodeRestriction' \
&& runs kube-apiserver '--admission-control-config-file=/etc/kubernetes/policywebhook/admission_config.yaml' && wait_api 90"""),
C("a Pod with nginx:latest is refused by the policy service, one with a pinned tag is created", 4, API + r"""
runs kube-apiserver 'ImagePolicyWebhook' && wait_api 60 || exit 1
$K -n default delete pod ksim-ipw-latest ksim-ipw-pinned --ignore-not-found --wait=true >/dev/null 2>&1
out=$($K -n default run ksim-ipw-latest --image=nginx:latest 2>&1); a=$?
$K -n default run ksim-ipw-pinned --image=busybox:1.36 -- sleep 60 >/dev/null 2>&1; b=$?
$K -n default delete pod ksim-ipw-latest ksim-ipw-pinned --ignore-not-found --wait=false >/dev/null 2>&1
[ $a -ne 0 ] && echo "$out" | grep -qi 'latest tag are not allowed' && [ $b -eq 0 ]"""),
C("denied.txt holds the refusal", 1, r"""grep -qi 'not allowed' "$WS/q79/denied.txt" 2>/dev/null"""),
],
"solution": """
sudo -i
cd /etc/kubernetes/policywebhook
vi admission_config.yaml # defaultAllow: false
vi kubeconf # server: https://localhost:1323/image_policy
cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# command: - --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook
# - --admission-control-config-file=/etc/kubernetes/policywebhook/admission_config.yaml
# volumeMounts: - {name: policywebhook, mountPath: /etc/kubernetes/policywebhook, readOnly: true}
# volumes: - {name: policywebhook, hostPath: {path: /etc/kubernetes/policywebhook, type: DirectoryOrCreate}}
watch crictl ps # until kube-apiserver is back
kubectl run test --image=nginx:latest 2>&1 | tee {WS}/q79/denied.txt # ... Images using latest tag are not allowed
kubectl run test2 --image=nginx:1.27 && kubectl delete pod test2
# The paths in the two files are paths INSIDE the API server's container - hence the mount of the
# whole directory at the same place. With defaultAllow: true a policy service that is down (or
# unreachable through a typo) lets everything through. If the API server does not come back:
# crictl logs $(crictl ps -a --name kube-apiserver -q | head -1) 2>&1 | tail
""",
},
{
"id": 80, "domain": "SUPPLY", "title": "No image without a pinned tag", "difficulty": "hard",
"params": lambda r: {"ns": ns_pick(r, "pinned")},
"task": """
In Namespace `%{ns}` (it carries the label `images=pinned`) every image must be pinned. Enforce it
with a **ValidatingAdmissionPolicy** — no webhook.
1. Create ValidatingAdmissionPolicy `no-latest` for Pods (on create and update): every container
**and init container** must name its image with an explicit tag that is not `latest`, or with a
digest (`@sha256:...`). A reference without a tag is refused — also one whose registry has a
port, such as `registry.local:5000/app`.
2. Create ValidatingAdmissionPolicyBinding `no-latest` that denies violations in Namespaces
labelled `images=pinned`, and nowhere else.
""",
"setup": NS + r"""
mkns %{ns}; mkns %{ns}-free
$K label ns %{ns} images=pinned --overwrite >/dev/null
$K delete validatingadmissionpolicybinding no-latest --ignore-not-found >/dev/null 2>&1
$K delete validatingadmissionpolicy no-latest --ignore-not-found >/dev/null 2>&1; true
""",
"checks": [
C("policy no-latest covers Pods; binding no-latest denies, for Namespaces labelled images=pinned", 2, PY + r"""
py validatingadmissionpolicy no-latest 'any("pods" in r.get("resources", []) for r in o["spec"].get("matchConstraints", {}).get("resourceRules", []))' || exit 1
py validatingadmissionpolicybinding no-latest '
s = o["spec"]
s.get("policyName") == "no-latest" and "Deny" in s.get("validationActions", []) and s.get("matchResources", {}).get("namespaceSelector", {}).get("matchLabels") == {"images": "pinned"}'"""),
C("in %{ns}: nginx:latest and nginx are refused, nginx:1.27 is admitted", 3, WOULD + r"""
refused %{ns} nginx:latest && refused %{ns} nginx && would %{ns} nginx:1.27"""),
C("a registry port is not taken for a tag, a digest is accepted, init containers are checked", 3, WOULD + r"""
refused %{ns} nginx:latest || exit 1
refused %{ns} registry.local:5000/app && would %{ns} registry.local:5000/app:2.1 \
&& would %{ns} nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000 && refused %{ns} nginx:1.27 busybox:latest && would %{ns} nginx:1.27 busybox:1.36"""),
C("other Namespaces are not affected", 2, WOULD + r"""refused %{ns} nginx:latest && would %{ns}-free nginx:latest && would default nginx"""),
],
"solution": """
cat <<'EOF' | k apply -f -
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: {name: no-latest}
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- {apiGroups: [""], apiVersions: ["v1"], operations: [CREATE, UPDATE], resources: [pods]}
validations:
- expression: >-
(object.spec.containers + (has(object.spec.initContainers) ? object.spec.initContainers : [])).all(c,
c.image.contains('@sha256:') || (c.image.matches('^.*:[^/:]+$') && !c.image.endsWith(':latest')))
message: every image needs a tag other than latest, or a digest
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: {name: no-latest}
spec:
policyName: no-latest
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels: {images: pinned}
EOF
k -n %{ns} run t --image=nginx --dry-run=server # refused, with the message
k -n %{ns} run t --image=registry.local:5000/app --dry-run=server # refused: 5000/app is not a tag
k -n %{ns} run t --image=nginx:1.27 --dry-run=server # admitted
# The regular expression asks for a colon followed by something without / or : up to the end -
# a tag - which a registry port (followed by /path) is not. `c.image.contains(':')` would have let
# registry.local:5000/app through. A policy does nothing without a binding; the binding says where
# (matchResources) and how (Deny, Warn, Audit). It takes a few seconds to become active.
""",
},
{
"id": 81, "domain": "SUPPLY", "title": "Credentials for a private registry",
"params": lambda r: {"ns": ns_pick(r, "internal"), "dom": r.choice(DOMS), "pw": "rp-" + _tok(r, 16)},
"task": """
The images of Namespace `%{ns}` come from the private registry `registry.%{dom}.internal:5000`.
Deployment `app` there cannot pull its image: no credentials. The registry's read-only account is
`puller`; its password is in `{WS}/q81/password.txt`.
1. Create a Secret `regcred` of the type for registry credentials in `%{ns}`, for that registry
and account.
2. Make **every** Pod that runs as the Namespace's `default` ServiceAccount use it, without
touching the workloads' manifests.
3. Re-create the Pods of Deployment `app`, so that they carry the credentials.
(The registry is not reachable from this cluster: the Pods will keep failing to pull. You are
graded on the credentials being in place.)
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q81"; mkdir -p "$WS/q81"; echo '%{pw}' > "$WS/q81/password.txt"
$K -n %{ns} create deploy app --image=registry.%{dom}.internal:5000/team/app:1.4 >/dev/null
""",
"checks": [
C("Secret regcred holds the registry, the account and the password, as kubernetes.io/dockerconfigjson", 4, r"""
eq "$(j -n %{ns} secret regcred '{.type}')" kubernetes.io/dockerconfigjson || exit 1
j -n %{ns} secret regcred '{.data.\.dockerconfigjson}' | base64 -d 2>/dev/null | python3 -c '
import base64, json, sys
a = json.load(sys.stdin)["auths"].get("registry.%{dom}.internal:5000", {})
u, p = a.get("username"), a.get("password")
if not u and a.get("auth"):
u, _, p = base64.b64decode(a["auth"]).decode().partition(":")
sys.exit(0 if (u, p) == ("puller", "%{pw}") else 1)'"""),
C("the default ServiceAccount hands it to its Pods", 3, r"""j -n %{ns} sa default '{.imagePullSecrets[*].name}' | tr ' ' '\n' | grep -qx regcred"""),
C("the Pods of Deployment app carry it", 3, PY + r"""
py -n %{ns} pod -l app=app '
p = sorted((x for x in o["items"] if "deletionTimestamp" not in x["metadata"]), key=lambda x: x["metadata"]["creationTimestamp"])
bool(p) and any(s.get("name") == "regcred" for s in p[-1]["spec"].get("imagePullSecrets", []))'"""),
],
"solution": """
k -n %{ns} create secret docker-registry regcred --docker-server=registry.%{dom}.internal:5000 \\
--docker-username=puller --docker-password="$(cat {WS}/q81/password.txt)"
k -n %{ns} patch sa default -p '{"imagePullSecrets": [{"name": "regcred"}]}'
k -n %{ns} rollout restart deploy app
k -n %{ns} get pod -l app=app -o jsonpath='{.items[*].spec.imagePullSecrets}'; echo
# imagePullSecrets on a ServiceAccount are copied into every Pod that uses it, when the Pod is
# created - which is why the existing Pods had to be replaced. The alternative is to name the
# Secret in each Pod template (spec.imagePullSecrets). The Secret must be in the Pods' Namespace.
""",
},
{
"id": 82, "domain": "SUPPLY", "title": "Apply only what is signed",
"params": lambda r: {"ns": ns_pick(r, "release"), "bad": r.choice(["payments", "catalog", "search"])},
"task": """
The release pipeline signs every manifest with `cosign`. `{WS}/q82/release/` holds three manifests
for Namespace `%{ns}` — `payments.yaml`, `catalog.yaml` and `search.yaml` — each with its signature
bundle (`<file>.bundle`), and the pipeline's public key `cosign.pub`.
1. Verify every manifest against its bundle with the public key (`cosign verify-blob`). The
signatures were made without a transparency log, so verification needs
`--insecure-ignore-tlog=true`.
2. Write the names of the files whose verification fails to `{WS}/q82/rejected.txt`, one per line.
3. Apply the manifests that verified, and only those.
""",
"setup": NS + r"""
mkns %{ns}; d="$WS/q82/release"; rm -rf "$WS/q82"; mkdir -p "$d"
for n in payments catalog search; do
cat > "$d/$n.yaml" <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: $n, namespace: %{ns}}
spec:
replicas: 1
selector: {matchLabels: {app: $n}}
template:
metadata: {labels: {app: $n}}
spec:
containers:
- name: $n
image: busybox:1.36
command: [sleep, "86400"]
EOF
done
nx "$CP" 'set -e; k=/root/.ksim-q82; rm -rf $k; mkdir -p $k; cd $k; COSIGN_PASSWORD="" cosign generate-key-pair >/dev/null 2>&1
cd /opt/course/q82/release; cp $k/cosign.pub .
for n in payments catalog search; do
COSIGN_PASSWORD="" cosign sign-blob --key $k/cosign.key --bundle $n.yaml.bundle --use-signing-config=false --tlog-upload=false -y $n.yaml >/dev/null 2>&1
chmod 644 $n.yaml.bundle
done; rm -rf $k' </dev/null || { echo "cosign could not sign the manifests" >&2; exit 1; }
# after signing, one manifest is changed: a privileged container that mounts the node
sed -i 's| command: \[sleep, "86400"\]| command: [sleep, "86400"]\n securityContext: {privileged: true}|' "$d/%{bad}.yaml"
grep -q privileged "$d/%{bad}.yaml" && [ -s "$d/search.yaml.bundle" ] && echo %{bad}.yaml > "$PRIV/q82.bad"
""",
"checks": [
C("rejected.txt names the manifest whose signature does not verify", 4, r"""
[ -s "$WS/q82/rejected.txt" ] && diff <(tr -d ' \r' < "$WS/q82/rejected.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q82.bad" >/dev/null"""),
C("the two verified Deployments are applied, as signed", 4, r"""
for n in payments catalog search; do
[ $n = %{bad} ] && continue
eq "$(j -n %{ns} deploy $n '{.spec.template.spec.containers[0].image}')" busybox:1.36 && [ "$(j -n %{ns} deploy $n '{.spec.template.spec.containers[0].securityContext.privileged}')" != true ] || exit 1
done"""),
C("the tampered one is not", 2, r"""
n=0; for d in payments catalog search; do [ -n "$(j -n %{ns} deploy $d '{.metadata.name}')" ] && n=$((n+1)); done
[ $n = 2 ] && [ -z "$(j -n %{ns} deploy %{bad} '{.metadata.name}')" ]"""),
],
"solution": """
cd {WS}/q82/release
for f in payments.yaml catalog.yaml search.yaml; do
echo "== $f"; cosign verify-blob --key cosign.pub --bundle $f.bundle --insecure-ignore-tlog=true $f
done # "Verified OK" twice, one error
echo %{bad}.yaml > ../rejected.txt
# k apply -f <each of the two that verified>
diff <(k create --dry-run=client -o yaml -f %{bad}.yaml) /dev/null | grep -i privileged # what was slipped in
# A signature covers the exact bytes of the file: one changed line and verification fails. The
# key pair belongs to the pipeline; the public half is all that is needed to verify. For images,
# the same check is `cosign verify --key cosign.pub IMAGE` - and an admission policy that does it
# for every Pod is what "validate artifacts" means in production.
""",
},
# ================================================================ 5.4 Static analysis ====
{
"id": 83, "domain": "SUPPLY", "title": "kubesec: from critical to a passing score",
"params": lambda r: {"ns": ns_pick(r, "audit"), "pod": name_pick(r, "exporter")},
"task": """
`{WS}/q83/pod.yaml` is about to be deployed to Namespace `%{ns}`. Scan it with `kubesec` on `{CP}`
and harden it:
1. Edit the manifest until kubesec reports **no critical finding** and a **score of 10 or more**.
Keep the Pod's name, Namespace, image and command.
2. Save kubesec's report of the final manifest to `{WS}/q83/report.json`.
3. Create the Pod from the manifest. It must be Running.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q83"; mkdir -p "$WS/q83"
cat > "$WS/q83/pod.yaml" <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: %{pod}
namespace: %{ns}
spec:
containers:
- name: exporter
image: busybox:1.36
command: ["sleep", "86400"]
securityContext:
privileged: true
capabilities:
add: ["SYS_ADMIN"]
EOF
""",
"checks": [
C("kubesec gives the manifest a score of 10 or more, with no critical finding", 4, r"""
nx "$CP" 'kubesec scan /opt/course/q83/pod.yaml 2>/dev/null' </dev/null | python3 -c '
import json, sys
r = json.load(sys.stdin)[0]
sys.exit(0 if r.get("object", "").startswith("Pod/%{pod}.") and r.get("score", -99) >= 10 and not r.get("scoring", {}).get("critical") else 1)'"""),
C("report.json is kubesec's report with that result", 2, r"""
python3 -c '
import json, sys
r = json.load(open(sys.argv[1]))[0]
sys.exit(0 if r.get("score", -99) >= 10 and not r.get("scoring", {}).get("critical") and "%{pod}" in r.get("object", "") else 1)' "$WS/q83/report.json" 2>/dev/null"""),
C("the Pod runs in %{ns}, hardened: not privileged, read-only root, non-root, capabilities dropped", 4, PY + r"""
py -n %{ns} pod %{pod} '
c = o["spec"]["containers"][0]; cs = c.get("securityContext", {}); ps = o["spec"].get("securityContext", {})
o["status"].get("phase") == "Running" and c["image"] == "busybox:1.36" and not cs.get("privileged") and cs.get("readOnlyRootFilesystem") is True and cs.get("runAsNonRoot", ps.get("runAsNonRoot")) is True and "ALL" in cs.get("capabilities", {}).get("drop", []) and not cs.get("capabilities", {}).get("add")'"""),
],
"solution": """
cd {WS}/q83
kubesec scan pod.yaml | jq '.[0] | {score, critical: .scoring.critical, advise: [.scoring.advise[] | .id + " +" + (.points|tostring)]}'
vi pod.yaml
# spec:
# serviceAccountName: default
# securityContext: {runAsNonRoot: true, runAsUser: 10001, seccompProfile: {type: RuntimeDefault}}
# containers:
# - ...
# securityContext:
# readOnlyRootFilesystem: true
# allowPrivilegeEscalation: false
# capabilities: {drop: [ALL]}
# resources:
# requests: {cpu: 50m, memory: 32Mi}
# limits: {cpu: 100m, memory: 64Mi}
kubesec scan pod.yaml | tee report.json | jq '.[0].score' # 13
k apply -f pod.yaml && k -n %{ns} get pod %{pod}
# kubesec's "critical" items subtract (privileged: -30, SYS_ADMIN: -30), the "advise" list is what
# would add points: each line names the field (selector) and what it is worth.
""",
},
{
"id": 84, "domain": "SUPPLY", "title": "KubeLinter in the pipeline",
"params": lambda r: {"ns": ns_pick(r, "jobs"), "dep": name_pick(r, "mailer")},
"task": """
The pipeline lints every manifest with `kube-linter` and these checks:
privileged-container, privilege-escalation-container, run-as-non-root, no-read-only-root-fs,
latest-tag, unset-cpu-requirements, unset-memory-requirements
`{WS}/q84/manifests/deployment.yaml` fails them.
1. Run `kube-linter lint` on the file and save its report to `{WS}/q84/before.txt`.
2. Fix the manifest until those seven checks pass. Pin the image to `busybox:1.36`; keep the
Deployment's name, Namespace and command.
3. Apply the manifest. The Deployment must be available.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q84"; mkdir -p "$WS/q84/manifests"
cat > "$WS/q84/manifests/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: %{dep}
namespace: %{ns}
labels:
app: %{dep}
spec:
replicas: 1
selector:
matchLabels:
app: %{dep}
template:
metadata:
labels:
app: %{dep}
spec:
containers:
- name: mailer
image: busybox:latest
command: ["sleep", "86400"]
securityContext:
privileged: true
EOF
""",
"checks": [
C("before.txt is kube-linter's report of the original manifest", 2, r"""
grep -q 'privileged-container' "$WS/q84/before.txt" 2>/dev/null && grep -q 'latest-tag' "$WS/q84/before.txt" """),
C("the manifest passes the seven checks", 5, r"""
f=/opt/course/q84/manifests/deployment.yaml
grep -q 'name: %{dep}' "$WS/q84/manifests/deployment.yaml" && grep -q 'image: *"\?busybox:1\.36' "$WS/q84/manifests/deployment.yaml" || exit 1
nx "$CP" "kube-linter lint --do-not-auto-add-defaults --include privileged-container,privilege-escalation-container,run-as-non-root,no-read-only-root-fs,latest-tag,unset-cpu-requirements,unset-memory-requirements $f" </dev/null >/dev/null 2>&1"""),
C("the Deployment runs that way: available, not privileged, non-root, read-only root, busybox:1.36", 3, PY + r"""
for i in $(seq 1 15); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
py -n %{ns} deploy %{dep} '
t = o["spec"]["template"]["spec"]; c = t["containers"][0]; cs = c.get("securityContext", {}); ps = t.get("securityContext", {})
o.get("status", {}).get("availableReplicas") == 1 and c["image"] == "busybox:1.36" and not cs.get("privileged") and cs.get("readOnlyRootFilesystem") is True and cs.get("runAsNonRoot", ps.get("runAsNonRoot")) is True and cs.get("allowPrivilegeEscalation") is False'"""),
],
"solution": """
cd {WS}/q84
kube-linter lint manifests/deployment.yaml | tee before.txt # each line: the finding, (check: NAME, remediation: ...)
vi manifests/deployment.yaml
# containers:
# - name: mailer
# image: busybox:1.36
# command: ["sleep", "86400"]
# securityContext:
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 10001
# resources:
# requests: {cpu: 50m, memory: 32Mi}
# limits: {cpu: 100m, memory: 64Mi}
kube-linter lint --do-not-auto-add-defaults --include privileged-container,privilege-escalation-container,run-as-non-root,no-read-only-root-fs,latest-tag,unset-cpu-requirements,unset-memory-requirements manifests/deployment.yaml
k apply -f manifests/deployment.yaml && k -n %{ns} rollout status deploy %{dep}
# `kube-linter checks list` describes every check. --include adds checks to the defaults;
# with --do-not-auto-add-defaults only the named ones run - which is how a pipeline pins its rules.
""",
},
{
"id": 85, "domain": "SUPPLY", "title": "Images with critical vulnerabilities",
"params": _names,
"task": """
Namespace `%{ns}` runs four Deployments: `%{d2}`, `%{d0}`, `%{d1}` and `%{d3}`.
1. Scan the image of each Deployment with `trivy` on `{CP}`.
2. Write the names of the Deployments whose image has at least one vulnerability of severity
**CRITICAL** to `{WS}/q85/critical.txt`, one per line.
3. Scale those Deployments to 0 replicas. Leave the others running.
""",
"setup": NS + TRIVY + r"""
mkns %{ns}; rm -rf "$WS/q85"; mkdir -p "$WS/q85"
$K -n %{ns} create deploy %{d0} --image=nginx:1.19.0 >/dev/null
$K -n %{ns} create deploy %{d1} --image=redis:5.0.0 >/dev/null
$K -n %{ns} create deploy %{d2} --image=nginx:1.27-alpine >/dev/null
$K -n %{ns} create deploy %{d3} --image=busybox:1.36 -- sleep 86400 >/dev/null
trivy_db || { echo "trivy could not fetch its vulnerability database" >&2; exit 1; }
: > "$PRIV/q85.critical"; : > "$PRIV/q85.clean"
for p in %{d0}=nginx:1.19.0 %{d1}=redis:5.0.0 %{d2}=nginx:1.27-alpine %{d3}=busybox:1.36; do
n=$(crit "${p#*=}"); [ -n "$n" ] || { echo "trivy could not scan ${p#*=}" >&2; exit 1; }
if [ "$n" -gt 0 ]; then echo "${p%%=*}" >> "$PRIV/q85.critical"; else echo "${p%%=*}" >> "$PRIV/q85.clean"; fi
done
for d in %{d0} %{d1} %{d2} %{d3}; do $K -n %{ns} rollout status deploy $d --timeout=240s >/dev/null || exit 1; done
[ -s "$PRIV/q85.critical" ]
""",
"checks": [
C("critical.txt names exactly the Deployments whose image has CRITICAL vulnerabilities", 5, SAME + r"""same "$WS/q85/critical.txt" "$PRIV/q85.critical" """),
C("those are scaled to 0, the others still run", 5, r"""
for d in $(cat "$PRIV/q85.critical"); do eq "$(j -n %{ns} deploy $d '{.spec.replicas}')" 0 || exit 1; done
for d in $(cat "$PRIV/q85.clean"); do eq "$(j -n %{ns} deploy $d '{.spec.replicas}')" 1 || exit 1; done"""),
],
"solution": """
k -n %{ns} get deploy -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image
for i in nginx:1.19.0 redis:5.0.0 nginx:1.27-alpine busybox:1.36; do
echo "$i $(trivy image -q --severity CRITICAL --format json $i | jq '[.Results[].Vulnerabilities[]?] | length')"
done
# the Deployments of the images with a number above 0 -> {WS}/q85/critical.txt
# k -n %{ns} scale deploy NAME --replicas=0 for each of them
# Scan the image the Pod spec names, tag included: nginx:1.19.0 and nginx:1.27-alpine are two
# different sets of packages. --severity filters the report; without it the handful of CRITICALs
# drown in hundreds of LOW and MEDIUM lines. `--exit-code 1` makes trivy fail a pipeline instead.
""",
},
{
"id": 86, "domain": "SUPPLY", "title": "trivy config: misconfigurations in a manifest",
"params": lambda r: {"ns": ns_pick(r, "edge"), "dep": name_pick(r, "relay")},
"task": """
`{WS}/q86/deployment.yaml` is waiting for review. Check it with `trivy config` on `{CP}`:
1. Write the number of misconfigurations of severity **HIGH** or **CRITICAL** that trivy reports
for the file to `{WS}/q86/count.txt`.
2. Fix the manifest until trivy reports none of those two severities. Keep the Deployment's name,
Namespace, image (`busybox:1.36`) and command.
3. Apply the manifest. The Deployment must be available.
""",
"setup": NS + TRIVY + r"""
mkns %{ns}; rm -rf "$WS/q86"; mkdir -p "$WS/q86"
cat > "$WS/q86/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: %{dep}
namespace: %{ns}
spec:
replicas: 1
selector:
matchLabels:
app: %{dep}
template:
metadata:
labels:
app: %{dep}
spec:
hostNetwork: true
hostPID: true
containers:
- name: relay
image: busybox:1.36
command: ["sleep", "86400"]
securityContext:
privileged: true
EOF
n=$(tv config -q --severity HIGH,CRITICAL --format json /opt/course/q86/deployment.yaml 2>/dev/null | count Misconfigurations)
[ "${n:-0}" -ge 3 ] && echo "$n" > "$PRIV/q86.count" || { echo "trivy config found nothing to fix (needs its checks bundle from the network)" >&2; exit 1; }
""",
"checks": [
C("count.txt holds the number of HIGH and CRITICAL misconfigurations of the original", 2, r"""
eq "$(tr -dc '0-9' < "$WS/q86/count.txt" 2>/dev/null)" "$(cat "$PRIV/q86.count")" """),
C("trivy reports no HIGH or CRITICAL misconfiguration for the manifest any more", 5, TRIVY + r"""
grep -q 'name: %{dep}' "$WS/q86/deployment.yaml" && grep -q 'busybox:1\.36' "$WS/q86/deployment.yaml" && grep -q 'kind: Deployment' "$WS/q86/deployment.yaml" || exit 1
n=$(tv config -q --severity HIGH,CRITICAL --format json /opt/course/q86/deployment.yaml 2>/dev/null | count Misconfigurations)
eq "$n" 0"""),
C("the Deployment is applied and available, without host namespaces or privilege", 3, PY + r"""
for i in $(seq 1 15); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
py -n %{ns} deploy %{dep} '
t = o["spec"]["template"]["spec"]; c = t["containers"][0]
o.get("status", {}).get("availableReplicas") == 1 and not t.get("hostNetwork") and not t.get("hostPID") and not c.get("securityContext", {}).get("privileged") and c["image"] == "busybox:1.36"'"""),
],
"solution": """
cd {WS}/q86
trivy config --severity HIGH,CRITICAL deployment.yaml # each finding: id, title, the lines, how to fix
trivy config -q --severity HIGH,CRITICAL --format json deployment.yaml | jq '[.Results[].Misconfigurations[]?] | length' > count.txt
vi deployment.yaml
# delete hostNetwork and hostPID
# securityContext of the container:
# privileged: false
# readOnlyRootFilesystem: true
# allowPrivilegeEscalation: false
# runAsNonRoot: true
# runAsUser: 10001
# capabilities: {drop: [ALL]}
trivy config --severity HIGH,CRITICAL deployment.yaml # until it reports nothing
k apply -f deployment.yaml && k -n %{ns} rollout status deploy %{dep}
# trivy config reads Kubernetes manifests, Dockerfiles, Helm charts and Terraform and checks them
# against its built-in policies (ids KSV-... for Kubernetes). Fix, scan, repeat: one fix often
# clears several findings, and the report prints the offending lines.
""",
},
]