From d1cf62353ecdf5f00edad030927ed7e2151e8fc1 Mon Sep 17 00:00:00 2001 From: Danil Malygin Date: Thu, 2 Jul 2026 14:01:52 +0300 Subject: [PATCH] feat(lab7): trivy + PSS restricted + conftest gate --- labs/lab7/k8s/deployment.yaml | 92 +++++++++++++++ labs/lab7/k8s/namespace.yaml | 8 ++ labs/lab7/k8s/networkpolicy.yaml | 29 +++++ labs/lab7/k8s/serviceaccount.yaml | 6 + labs/lab7/policies/pod-hardening.rego | 42 +++++++ submissions/lab7.md | 161 ++++++++++++++++++++++++++ 6 files changed, 338 insertions(+) create mode 100644 labs/lab7/k8s/deployment.yaml create mode 100644 labs/lab7/k8s/namespace.yaml create mode 100644 labs/lab7/k8s/networkpolicy.yaml create mode 100644 labs/lab7/k8s/serviceaccount.yaml create mode 100644 labs/lab7/policies/pod-hardening.rego create mode 100644 submissions/lab7.md diff --git a/labs/lab7/k8s/deployment.yaml b/labs/lab7/k8s/deployment.yaml new file mode 100644 index 00000000..312d7763 --- /dev/null +++ b/labs/lab7/k8s/deployment.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: juice-shop + namespace: juice-shop +spec: + replicas: 1 + selector: + matchLabels: + app: juice-shop + template: + metadata: + labels: + app: juice-shop + spec: + serviceAccountName: juice-shop-sa + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + initContainers: + - name: init-data + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + command: + - /nodejs/bin/node + - -e + # Элегантный цикл клонирования всех нужных директорий + - "const fs=require('fs');['data','.well-known','frontend','i18n'].forEach(d=>fs.cpSync('/juice-shop/'+d,'/mnt/'+d,{recursive:true}));" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: data-vol + mountPath: /mnt/data + - name: well-known-vol + mountPath: /mnt/.well-known + - name: frontend-vol + mountPath: /mnt/frontend + - name: i18n-vol + mountPath: /mnt/i18n + containers: + - name: juice-shop + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + ports: + - containerPort: 3000 + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: tmp-vol + mountPath: /tmp + - name: logs-vol + mountPath: /juice-shop/logs + - name: ftp-vol + mountPath: /juice-shop/ftp + - name: data-vol + mountPath: /juice-shop/data + - name: well-known-vol + mountPath: /juice-shop/.well-known + - name: frontend-vol + mountPath: /juice-shop/frontend + - name: i18n-vol + mountPath: /juice-shop/i18n + volumes: + - name: tmp-vol + emptyDir: {} + - name: logs-vol + emptyDir: {} + - name: ftp-vol + emptyDir: {} + - name: data-vol + emptyDir: {} + - name: well-known-vol + emptyDir: {} + - name: frontend-vol + emptyDir: {} + - name: i18n-vol + emptyDir: {} diff --git a/labs/lab7/k8s/namespace.yaml b/labs/lab7/k8s/namespace.yaml new file mode 100644 index 00000000..67b3c6d4 --- /dev/null +++ b/labs/lab7/k8s/namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: juice-shop + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/audit: restricted diff --git a/labs/lab7/k8s/networkpolicy.yaml b/labs/lab7/k8s/networkpolicy.yaml new file mode 100644 index 00000000..93ad193d --- /dev/null +++ b/labs/lab7/k8s/networkpolicy.yaml @@ -0,0 +1,29 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: juice-shop-netpol + namespace: juice-shop +spec: + podSelector: + matchLabels: + app: juice-shop + policyTypes: + - Ingress + - Egress + + ingress: + - ports: + - protocol: TCP + port: 3000 + + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - ports: + - protocol: TCP + port: 443 diff --git a/labs/lab7/k8s/serviceaccount.yaml b/labs/lab7/k8s/serviceaccount.yaml new file mode 100644 index 00000000..fc24f5a3 --- /dev/null +++ b/labs/lab7/k8s/serviceaccount.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: juice-shop-sa + namespace: juice-shop +automountServiceAccountToken: false diff --git a/labs/lab7/policies/pod-hardening.rego b/labs/lab7/policies/pod-hardening.rego new file mode 100644 index 00000000..562b471b --- /dev/null +++ b/labs/lab7/policies/pod-hardening.rego @@ -0,0 +1,42 @@ +package main + +import rego.v1 + +all_containers contains c if { + c := input.spec.template.spec.containers[_] +} +all_containers contains c if { + c := input.spec.template.spec.initContainers[_] +} + +deny contains msg if { + input.kind == "Deployment" + pod_spec := input.spec.template.spec + not pod_spec.securityContext.runAsNonRoot == true + msg := sprintf("Deployment '%s' missing pod-level runAsNonRoot=true", [input.metadata.name]) +} + +deny contains msg if { + input.kind == "Deployment" + container := all_containers[_] + not container.securityContext.readOnlyRootFilesystem == true + msg := sprintf("Container '%s' is missing readOnlyRootFilesystem=true", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + container := all_containers[_] + not container.securityContext.allowPrivilegeEscalation == false + msg := sprintf("Container '%s' must have allowPrivilegeEscalation set to false", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + container := all_containers[_] + not drops_all_capabilities(container) + msg := sprintf("Container '%s' must drop 'ALL' capabilities", [container.name]) +} + +drops_all_capabilities(container) if { + container.securityContext.capabilities.drop[_] == "ALL" +} diff --git a/submissions/lab7.md b/submissions/lab7.md new file mode 100644 index 00000000..bb014768 --- /dev/null +++ b/submissions/lab7.md @@ -0,0 +1,161 @@ +# Lab 7 — Submission + +## Task 1: Trivy Image + Config Scan + +### Image scan severity breakdown +| Severity | Total | With fix available | +|----------|------:|------------------:| +| Critical | 5 | 4 | +| High | 43 | 42 | +| **Total** | 48 | 46 | + +### Top 10 CVEs with fixes +| CVE | Severity | Package | Installed | Fix | +|-----|----------|---------|-----------|-----| +| CVE-2023-46233 | CRITICAL | crypto-js | 3.3.0 | 4.2.0 | +| CVE-2015-9235 | CRITICAL | jsonwebtoken | 0.1.0 | 4.2.2 | +| CVE-2015-9235 | CRITICAL | jsonwebtoken | 0.4.0 | 4.2.2 | +| CVE-2019-10744 | CRITICAL | lodash | 2.4.2 | 4.17.12 | +| CVE-2026-45447 | HIGH | libssl3t64 | 3.5.5-1~deb13u2 | 3.5.6-1~deb13u2 | +| NSWG-ECO-428 | HIGH | base64url | 0.0.6 | >=3.0.0 | +| CVE-2020-15084 | HIGH | express-jwt | 0.1.3 | 6.0.0 | +| CVE-2022-25881 | HIGH | http-cache-semantics | 3.8.1 | 4.1.1 | +| CVE-2022-23539 | HIGH | jsonwebtoken | 0.1.0 | 9.0.0 | +| NSWG-ECO-17 | HIGH | jsonwebtoken | 0.1.0 | >=4.2.2 | + +### Compared to Lab 4's Grype scan +Look back at your Lab 4 Grype results on the same image. Pick **two CVEs**: +**1. One that BOTH Grype and Trivy found:** `CVE-2019-10744` (in the `lodash` package). +Both scanners reliably detect this because it is a highly ubiquitous, well-documented vulnerability in the National Vulnerability Database (NVD). Since both Anchore's feed (Grype) and Aqua's Vulnerability DB (Trivy) aggregate baseline NVD data and standard OS package indices, core ecosystem flaws are matched equally well by both tools. + +**2. One that ONE tool found and the OTHER missed:** `NSWG-ECO-428` (in the `base64url` package). +Trivy detected this ecosystem-specific advisory, while Grype often misses non-standard identifiers. This highlights a difference in database aggregation: Trivy is notably more aggressive at pulling in developer-centric feeds like GitHub Security Advisories (GHSA) and Node Security Working Group (NSWG) trackers. Grype tends to rely more heavily on formal CVE assignments and strict package-to-CPE matching, which can sometimes cause it to overlook language-specific ecosystem alerts that haven't been fully migrated to the standard NVD format. + +## Task 2: Kubernetes Hardening + +### Manifests (paste relevant snippets) +- `namespace.yaml` PSS labels: +```yaml + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/audit: restricted + +``` +- `deployment.yaml` securityContext sections (pod + container): +```yaml + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` +- `networkpolicy.yaml` ingress + egress: +```yaml + ingress: + - ports: + - protocol: TCP + port: 3000 + + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - ports: + - protocol: TCP + port: 443 +``` + +### Pod is running +Output of `kubectl get pod -n juice-shop -l app=juice-shop`: +``` +NAME READY STATUS RESTARTS AGE +juice-shop-6479846b85-2qrgx 1/1 Running 0 2m36s +``` + +### Trivy K8s scan +| Severity | Count | +|----------|------:| +| Critical | 10 | +| High | 90 | + +### What broke and how you fixed it (2-3 sentences) +Setting `readOnlyRootFilesystem: true` broke the Juice Shop application because it dynamically generates and modifies files at startup across multiple directories. To fix this, I mounted ephemeral `emptyDir` volumes to the required paths (e.g., `/juice-shop/data`, `/juice-shop/frontend`, `/juice-shop/.well-known`). Since the distroless image lacks standard shell utilities, I also added an `initContainer` with an inline Node.js script to recursively clone the original image files into these empty volumes before the main container starts. + +## Bonus: Conftest Policy + +### Policy (paste labs/lab7/policies/pod-hardening.rego) +```rego +package main + +import rego.v1 + +all_containers contains c if { + c := input.spec.template.spec.containers[_] +} +all_containers contains c if { + c := input.spec.template.spec.initContainers[_] +} + +deny contains msg if { + input.kind == "Deployment" + pod_spec := input.spec.template.spec + not pod_spec.securityContext.runAsNonRoot == true + msg := sprintf("Deployment '%s' missing pod-level runAsNonRoot=true", [input.metadata.name]) +} + +deny contains msg if { + input.kind == "Deployment" + container := all_containers[_] + not container.securityContext.readOnlyRootFilesystem == true + msg := sprintf("Container '%s' is missing readOnlyRootFilesystem=true", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + container := all_containers[_] + not container.securityContext.allowPrivilegeEscalation == false + msg := sprintf("Container '%s' must have allowPrivilegeEscalation set to false", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + container := all_containers[_] + not drops_all_capabilities(container) + msg := sprintf("Container '%s' must drop 'ALL' capabilities", [container.name]) +} + +drops_all_capabilities(container) if { + container.securityContext.capabilities.drop[_] == "ALL" +} +``` + +### Output: PASS on hardened manifest +``` +4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions + +``` + +### Output: FAIL on bad manifest +``` +FAIL - /tmp/bad-pod.yaml - main - Container 'app' is missing readOnlyRootFilesystem=true +FAIL - /tmp/bad-pod.yaml - main - Container 'app' must drop 'ALL' capabilities +FAIL - /tmp/bad-pod.yaml - main - Container 'app' must have allowPrivilegeEscalation set to false +FAIL - /tmp/bad-pod.yaml - main - Deployment 'bad-app' missing pod-level runAsNonRoot=true + +4 tests, 0 passed, 0 warnings, 4 failures, 0 exceptions + +``` + +### What this prevents at CI time (2-3 sentences) +This policy catches infrastructure misconfigurations—specifically missing `securityContext` boundaries—during the CI pipeline before `kubectl apply` sends them to the cluster. As illustrated in the admission control diagram (Lecture 7 slide 16), catching these flaws at CI-time enables a true "shift-left" approach, providing instant feedback to developers rather than relying on late-stage admission webhooks to reject the deployment.