"""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 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 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 < "$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 < "$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 < "$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`: ` `. 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 (`@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 && 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 < kubeconf < /etc/kubernetes/manifests/image-policy-service.yaml <&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 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 (`.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" </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' &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 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' = 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 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. """, }, ]