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
1110 lines
56 KiB
Python
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.
|
|
""",
|
|
},
|
|
]
|