diff --git a/labs/lab7/k8s/deployment.yaml b/labs/lab7/k8s/deployment.yaml new file mode 100644 index 00000000..79be306d --- /dev/null +++ b/labs/lab7/k8s/deployment.yaml @@ -0,0 +1,191 @@ +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: data-populate + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + command: + - /nodejs/bin/node + - -e + - | + const fs = require('fs'); + const path = require('path'); + const src = '/juice-shop/data'; + const dst = '/data'; + const copy = (srcDir, dstDir) => { + fs.mkdirSync(dstDir, { recursive: true }); + for (const name of fs.readdirSync(srcDir)) { + const srcPath = path.join(srcDir, name); + const dstPath = path.join(dstDir, name); + const stat = fs.statSync(srcPath); + if (stat.isDirectory()) { + copy(srcPath, dstPath); + } else { + fs.copyFileSync(srcPath, dstPath); + } + } + }; + copy(src, dst); + volumeMounts: + - name: data + mountPath: /data + - name: csaf-populate + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + command: + - /nodejs/bin/node + - -e + - | + const fs = require('fs'); + const path = require('path'); + const src = '/juice-shop/.well-known/csaf'; + const dst = '/csaf'; + const copy = (srcDir, dstDir) => { + fs.mkdirSync(dstDir, { recursive: true }); + for (const name of fs.readdirSync(srcDir)) { + const srcPath = path.join(srcDir, name); + const dstPath = path.join(dstDir, name); + const stat = fs.statSync(srcPath); + if (stat.isDirectory()) { + copy(srcPath, dstPath); + } else { + fs.copyFileSync(srcPath, dstPath); + } + } + }; + if (fs.existsSync(src)) { + copy(src, dst); + } + volumeMounts: + - name: csaf + mountPath: /csaf + - name: frontend-populate + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + command: + - /nodejs/bin/node + - -e + - | + const fs = require('fs'); + const path = require('path'); + const src = '/juice-shop/frontend/dist'; + const dst = '/frontend-dist'; + const copy = (srcDir, dstDir) => { + fs.mkdirSync(dstDir, { recursive: true }); + for (const name of fs.readdirSync(srcDir)) { + const srcPath = path.join(srcDir, name); + const dstPath = path.join(dstDir, name); + const stat = fs.statSync(srcPath); + if (stat.isDirectory()) { + copy(srcPath, dstPath); + } else { + fs.copyFileSync(srcPath, dstPath); + } + } + }; + if (fs.existsSync(src)) { + copy(src, dst); + } + volumeMounts: + - name: frontend-dist + mountPath: /frontend-dist + containers: + - name: juice-shop + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: "100m" + memory: "200Mi" + limits: + cpu: "500m" + memory: "512Mi" + volumeMounts: + - name: tmp + mountPath: /tmp + - name: logs + mountPath: /usr/src/app/logs + - name: logs + mountPath: /juice-shop/logs + - name: ftp + mountPath: /juice-shop/ftp + - name: videos + mountPath: /juice-shop/frontend/dist/frontend/assets/public/videos + - name: csaf + mountPath: /juice-shop/.well-known/csaf + - name: data + mountPath: /juice-shop/data + - name: i18n + mountPath: /juice-shop/i18n + - name: frontend-dist + mountPath: /juice-shop/frontend/dist + volumes: + - name: tmp + emptyDir: {} + - name: logs + emptyDir: {} + - name: ftp + emptyDir: {} + - name: videos + emptyDir: {} + - name: csaf + emptyDir: {} + - name: data + emptyDir: {} + - name: i18n + emptyDir: {} + - name: frontend-dist + 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..1b271a69 --- /dev/null +++ b/labs/lab7/k8s/networkpolicy.yaml @@ -0,0 +1,33 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: juice-shop-policy + namespace: juice-shop +spec: + podSelector: + matchLabels: + app: juice-shop + policyTypes: + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + - ipBlock: + cidr: 127.0.0.1/32 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + 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..ebc21099 --- /dev/null +++ b/labs/lab7/policies/pod-hardening.rego @@ -0,0 +1,34 @@ +package main + +# Helper: true if array arr contains value v +has_value(arr, v) if { + some i + arr[i] == v +} + +deny contains msg if { + input.kind == "Deployment" + not input.spec.template.spec.securityContext.runAsNonRoot == true + msg := "pod spec must set spec.securityContext.runAsNonRoot: true" +} + +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.readOnlyRootFilesystem == true + msg := sprintf("container %q must set securityContext.readOnlyRootFilesystem: true", [c.name]) +} + +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.allowPrivilegeEscalation == false + msg := sprintf("container %q must set securityContext.allowPrivilegeEscalation: false", [c.name]) +} + +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not has_value(c.securityContext.capabilities.drop, "ALL") + msg := sprintf("container %q must drop ALL capabilities", [c.name]) +} diff --git a/submissions/lab7.md b/submissions/lab7.md new file mode 100644 index 00000000..425739b1 --- /dev/null +++ b/submissions/lab7.md @@ -0,0 +1,154 @@ + +# Lab 7 — Submission + +## Task 1: Trivy Image + Config Scan + +### Image scan severity breakdown +| Severity | Total | With fix available | +|----------|------:|------------------:| +| Critical | 5 | 5 | +| High | 35 | 35 | +| **Total** | 40 | 40 | + +### Top 10 CVEs with fixes +| CVE | Severity | Package | Installed | Fix | +|-----|----------|---------|-----------|-----| +| CVE-2023-46233 | CRITICAL | crypto-js | 4.2.0 | +| CVE-2015-9235 | CRITICAL | jsonwebtoken | 4.2.2 | +| CVE-2015-9235 | CRITICAL | jsonwebtoken | 4.2.2 | +| CVE-2019-10744 | CRITICAL | lodash | 4.17.12 | +| CVE-2026-45447 | HIGH | libssl3t64 | 3.5.6-1~deb13u2 | +| NSWG-ECO-428 | HIGH | base64url | >=3.0.0 | +| CVE-2020-15084 | HIGH | express-jwt | 6.0.0 | +| CVE-2022-25881 | HIGH | http-cache-semantics | 4.1.1 | +| CVE-2022-23539 | HIGH | jsonwebtoken | 9.0.0 | +| NSWG-ECO-17 | HIGH | jsonwebtoken | >=4.2.2 | + +### Compared to Lab 4's Grype scan +**CVE-2026-45447 (libssl3t64)** +Both Grype and Trivy found this vulnerability because it is a Debian package issue with a clear package name/version match and a well-established CVE identifier. The overlap shows that both scanners can detect OS-level vulnerabilities in the same image when the package metadata and advisory mapping are aligned. + +**NSWG-ECO-428 (base64url)** +Trivy found NSWG-ECO-428, but Grype did not report it in the Lab 4 SBOM-based output, likely because Trivy’s vulnerability database includes this npm advisory ID and its npm package matching is more current for this library. Grype’s result set can differ due to DB freshness and advisory coverage differences, especially for non-CVE or ecosystem-specific advisory IDs in JavaScript dependencies. + +## 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: +- from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system +egress: +- to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + 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`: +``` +juice-shop-7db7ccf4fc-hj5px ready=true phase=Running restarts=0 +``` + +### Trivy K8s scan +| Severity | Count | +|----------|------:| +| Critical | 20 | +| High | 172 | + +### What broke and how you fixed it (2-3 sentences) +`readOnlyRootFilesystem: true` caused Juice Shop to fail when it tried to write runtime files under `/juice-shop/frontend/dist/frontend/index.html` and related frontend assets. I fixed it by mounting `emptyDir` volumes for the writable application paths, including `/tmp`, `/usr/src/app/logs`, `/juice-shop/logs`, `/juice-shop/ftp`, `/juice-shop/data`, `/juice-shop/i18n`, and `/juice-shop/frontend/dist`, while keeping the container root filesystem read-only. + +## Bonus: Conftest Policy + +### Policy (paste labs/lab7/policies/pod-hardening.rego) +```rego +package main + +# Helper: true if array arr contains value v +has_value(arr, v) if { + some i + arr[i] == v +} + +deny contains msg if { + input.kind == "Deployment" + not input.spec.template.spec.securityContext.runAsNonRoot == true + msg := "pod spec must set spec.securityContext.runAsNonRoot: true" +} + +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.readOnlyRootFilesystem == true + msg := sprintf("container %q must set securityContext.readOnlyRootFilesystem: true", [c.name]) +} + +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not c.securityContext.allowPrivilegeEscalation == false + msg := sprintf("container %q must set securityContext.allowPrivilegeEscalation: false", [c.name]) +} + +deny contains msg if { + input.kind == "Deployment" + c := input.spec.template.spec.containers[_] + not has_value(c.securityContext.capabilities.drop, "ALL") + msg := sprintf("container %q must drop ALL capabilities", [c.name]) +} +``` + +### 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" must set securityContext.allowPrivilegeEscalation: false +FAIL - /tmp/bad-pod.yaml - main - container "app" must set securityContext.readOnlyRootFilesystem: true +FAIL - /tmp/bad-pod.yaml - main - pod spec must set spec.securityContext.runAsNonRoot: true + +4 tests, 1 passed, 0 warnings, 3 failures, 0 exceptions +``` + +### What this prevents at CI time (2-3 sentences) +This policy catches insecure pod definitions before `kubectl apply` reaches the cluster by validating manifest hardening rules at PR/CI time. Catching these issues in CI prevents insecure workloads from being deployed at all, which is stronger than admission-time detection because it avoids risk and review churn before the cluster sees the resource. +