From 22ad763583bd0b46d70807d15a5e0b59a74a97cc Mon Sep 17 00:00:00 2001 From: raller Date: Fri, 12 Jun 2026 07:31:29 -0400 Subject: [PATCH 1/5] feat(lab1): add CI smoke test workflow --- .github/workflows/lab1-smoke.yml | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/lab1-smoke.yml diff --git a/.github/workflows/lab1-smoke.yml b/.github/workflows/lab1-smoke.yml new file mode 100644 index 000000000..0f1f17093 --- /dev/null +++ b/.github/workflows/lab1-smoke.yml @@ -0,0 +1,49 @@ +name: Lab 1 — Juice Shop Smoke Test + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + smoke-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Pull and run Juice Shop + run: | + docker run -d --name juice-shop \ + -p 127.0.0.1:3000:3000 \ + bkimminich/juice-shop:v20.0.0 + + - name: Wait for Juice Shop to be healthy + run: | + echo "Waiting for Juice Shop to start..." + for i in $(seq 1 30); do + if curl --silent --fail http://127.0.0.1:3000/rest/admin/application-version >/dev/null; then + echo "Juice Shop is up!" + exit 0 + fi + echo "Attempt $i/30 — not ready yet, sleeping 2s..." + sleep 2 + done + echo "Juice Shop failed to start within 60s" + docker logs juice-shop + exit 1 + + - name: Verify homepage returns HTTP 200 + run: | + curl -I -s http://127.0.0.1:3000 | head -5 + curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://127.0.0.1:3000 + + - name: Verify product API + run: | + curl -s http://127.0.0.1:3000/api/Products | jq '.data | length' + + - name: Verify version endpoint + run: | + curl -s http://127.0.0.1:3000/rest/admin/application-version | jq From a838731b40d08503e8db54f6d3085ddb5f948f43 Mon Sep 17 00:00:00 2001 From: raller Date: Fri, 12 Jun 2026 07:56:29 -0400 Subject: [PATCH 2/5] feat(lab1): juice shop deploy + PR template + triage report --- .github/PULL_REQUEST_TEMPLATE.md | 24 ++++++++ submissions/lab1.md | 100 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 submissions/lab1.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..0092ff1b1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Goal + + +## Changes + +- +- +- + +## Testing + +```bash +# +# +``` + +## Artifacts & Screenshots + +- + +## Checklist +- [ ] Title is clear (`feat(labN): ` style) +- [ ] No secrets/large temp files committed +- [ ] Submission file at `submissions/labN.md` exists diff --git a/submissions/lab1.md b/submissions/lab1.md new file mode 100644 index 000000000..59c20ab82 --- /dev/null +++ b/submissions/lab1.md @@ -0,0 +1,100 @@ +# Lab 1 — Submission + +## Triage Report: OWASP Juice Shop + +### Scope & Asset +- Asset: OWASP Juice Shop (local lab instance) +- Image: `bkimminich/juice-shop:v20.0.0` +- Image digest: `sha256:99779f57113bd47312e8fe7b264ff402ee41da76ddda7f2fc842a92ad51827ce` +- Host OS: REMnux (Ubuntu 20.04-based) +- Docker version: `Docker version 26.1.3, build 26.1.3-0ubuntu1~20.04.1` + +### Deployment Details +- Run command used: `docker run -d --name juice-shop -p 127.0.0.1:3000:3000 bkimminich/juice-shop:v20.0.0` +- Access URL: http://127.0.0.1:3000 +- Network exposure: 127.0.0.1 only? [x] Yes [ ] No +- Container restart policy: default `no` (no `--restart` flag used) + +### Health Check +- HTTP code on `/`: `200` +- API check (first 200 chars of `/api/Products`): + ```json + {"status":"success","data":[{"id":1,"name":"Apple Juice (1000ml)","description":"The all-time classic.","price":1.99,"deluxePrice":0.99,"image":"apple_juice.jpg","createdAt":"2026-06-12T10:31:40.266Z" + ``` +- Container uptime: 4bd57343c74b bkimminich/juice-shop:v20.0.0 "/nodejs/bin/node /j…" 14 minutes ago Up 14 minutes 127.0.0.1:3000->3000/tcp juice-shop + +### Initial Surface Snapshot (from browser exploration) +- Login/Registration visible: [x] Yes [ ] No — notes: Login and Sign Up buttons present +- Product listing/search present: [x] Yes [ ] No — notes: Product cards displayed on homepage, search field available +- Admin or account area discoverable: [ ] Yes [x] No — notes: No direct admin link on landing page; authentication required +- Client-side errors in DevTools console: [ ] Yes [x] No — notes: Console clean, no errors detected +- Pre-populated local storage / cookies: language (set to 'en'), token (empty until login) + + +### Security Headers (Quick Look) +HTTP/1.1 200 OK +Access-Control-Allow-Origin: * +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +Feature-Policy: payment 'self' +X-Recruiting: /#/jobs +Accept-Ranges: bytes +Cache-Control: public, max-age=0 +Last-Modified: Fri, 12 Jun 2026 10:31:40 GMT +ETag: W/"26af-19ebb633283" +Content-Type: text/html; charset=UTF-8 +Content-Length: 9903 +Vary: Accept-Encoding +Date: Fri, 12 Jun 2026 10:34:55 GMT +Connection: keep-alive +Keep-Alive: timeout=5 + +Which of these are MISSING? (cross-reference Lecture 1 OWASP Top 10:2025 — A06) +- [x] `Content-Security-Policy` - MISSING +- [x] `Strict-Transport-Security` - MISSING +- [ ] `X-Content-Type-Options: nosniff` +- [ ] `X-Frame-Options` + +### Top 3 Risks Observed (2-3 sentences each, in your own words) +1. Broken Access Control (A01) — The API endpoint /api/Products//reviews returns data without authentication. This allows an unauthenticated attacker to read other users' reviews and potentially exfiltrate data, violating the principle of least privilege. +2. Cryptographic Failures (A02) — Absence of the HSTS header and operation over HTTP (in local environment) means that in production traffic could be intercepted. If the application transmits credentials or tokens without TLS, this leads to sensitive data exposure. +3. Security Misconfiguration (A05) — The complete absence of CSP and HSTS indicates a default 'open' configuration. Combined with intentionally vulnerable Juice Shop code, this creates a broad surface for XSS, clickjacking, and MIME-sniffing attacks. + +## PR Template Setup + +- File: `.github/PULL_REQUEST_TEMPLATE.md` +- Sections included: Goal / Changes / Testing / Artifacts & Screenshots +- Checklist items: + - Title is clear (`feat(labN): ` style) + - No secrets/large temp files committed + - Submission file at `submissions/labN.md` exists +- Auto-fill verified: [x] Yes — PR description showed my template (screenshot or link to draft PR) + +## GitHub Community + +### Actions Completed +- [x] Starred course repository +- [x] Starred [simple-container-com/api](https://github.com/simple-container-com/api) +- [x] Following Professor [@Cre-eD](https://github.com/Cre-eD) +- [x] Following TA [@Naghme98](https://github.com/Naghme98) +- [x] Following TA [@pierrepicaud](https://github.com/pierrepicaud) +- [x] Following 3+ classmates: `<, @username2, @username3>` + +### Why Stars Matter in Open Source +Stars are the currency of attention in the open-source ecosystem. A repository with 1000+ stars attracts more contributors and sponsors than an equivalent one with 10 stars. + +## Bonus: CI Smoke Test + +- Workflow file: `.github/workflows/lab1-smoke.yml` +- Trigger: `pull_request` on main +- Run URL (must be green): https://github.com/raaller/DevSecOps-Intro/actions/runs/27413678469 +- Workflow run duration: 27s +- Curl response excerpt: + ``` + HTTP/1.1 200 OK + Access-Control-Allow-Origin: * + X-Content-Type-Options: nosniff + X-Frame-Options: SAMEORIGIN + Feature-Policy: payment 'self' + HTTP Status: 200 + ``` From fe4ae7ec4217f1a31ab3863869934a99f6c26986 Mon Sep 17 00:00:00 2001 From: raller Date: Fri, 12 Jun 2026 07:57:42 -0400 Subject: [PATCH 3/5] feat(lab1): juice shop deploy + PR template + triage report --- submissions/lab1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submissions/lab1.md b/submissions/lab1.md index 59c20ab82..d7bb7a6c1 100644 --- a/submissions/lab1.md +++ b/submissions/lab1.md @@ -78,7 +78,7 @@ Which of these are MISSING? (cross-reference Lecture 1 OWASP Top 10:2025 — A06 - [x] Following Professor [@Cre-eD](https://github.com/Cre-eD) - [x] Following TA [@Naghme98](https://github.com/Naghme98) - [x] Following TA [@pierrepicaud](https://github.com/pierrepicaud) -- [x] Following 3+ classmates: `<, @username2, @username3>` +- [x] Following 3+ classmates ### Why Stars Matter in Open Source Stars are the currency of attention in the open-source ecosystem. A repository with 1000+ stars attracts more contributors and sponsors than an equivalent one with 10 stars. From 7f777bf2b97dee0362254be52e4d9aba16948893 Mon Sep 17 00:00:00 2001 From: raller Date: Thu, 18 Jun 2026 15:29:27 -0400 Subject: [PATCH 4/5] test: first signed commit --- submissions/lab3.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 submissions/lab3.md diff --git a/submissions/lab3.md b/submissions/lab3.md new file mode 100644 index 000000000..61fa24bee --- /dev/null +++ b/submissions/lab3.md @@ -0,0 +1 @@ +lab3 signing test From a42e8da890dc9f1c131ae6fd062ecc9f2b2ceaba Mon Sep 17 00:00:00 2001 From: raller Date: Thu, 2 Jul 2026 19:14:48 -0400 Subject: [PATCH 5/5] feat(lab7): trivy + PSS restricted + conftest gate --- labs/lab7/k8s/deployment.yaml | 161 ++++++++++++ labs/lab7/k8s/namespace.yaml | 8 + labs/lab7/k8s/networkpolicy.yaml | 42 ++++ labs/lab7/k8s/serviceaccount.yaml | 6 + labs/lab7/policies/pod-hardening.rego | 35 +++ submissions/lab7.md | 342 ++++++++++++++++++++++++++ 6 files changed, 594 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 000000000..4780aa740 --- /dev/null +++ b/labs/lab7/k8s/deployment.yaml @@ -0,0 +1,161 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: juice-shop + namespace: juice-shop + labels: + app: juice-shop +spec: + replicas: 1 + progressDeadlineSeconds: 600 + selector: + matchLabels: + app: juice-shop + template: + metadata: + labels: + app: juice-shop + spec: + serviceAccountName: juice-shop-sa + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + + # Writable application directories contain seed files in the image. + # The init container copies those files into emptyDir volumes before + # the main container starts with a read-only root filesystem. + initContainers: + - name: initialize-writable-directories + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + imagePullPolicy: IfNotPresent + command: + - /nodejs/bin/node + args: + - -e + - | + const fs = require('fs'); + const path = require('path'); + + const copies = [ + ['/juice-shop/data', '/writable/data'], + ['/juice-shop/ftp', '/writable/ftp'], + ['/juice-shop/i18n', '/writable/i18n'], + ['/juice-shop/logs', '/writable/logs'], + ['/juice-shop/frontend/dist', '/writable/frontend-dist'], + ['/juice-shop/.well-known/csaf', '/writable/csaf'] + ]; + + for (const [source, destination] of copies) { + fs.mkdirSync(destination, { recursive: true }); + if (!fs.existsSync(source)) { + continue; + } + for (const entry of fs.readdirSync(source)) { + fs.cpSync( + path.join(source, entry), + path.join(destination, entry), + { recursive: true, force: true } + ); + } + } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + volumeMounts: + - name: data + mountPath: /writable/data + - name: ftp + mountPath: /writable/ftp + - name: i18n + mountPath: /writable/i18n + - name: logs + mountPath: /writable/logs + - name: frontend-dist + mountPath: /writable/frontend-dist + - name: csaf + mountPath: /writable/csaf + + containers: + - name: juice-shop + image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 500m + memory: 1Gi + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 18 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 60 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + volumeMounts: + - name: tmp + mountPath: /tmp + - name: data + mountPath: /juice-shop/data + - name: ftp + mountPath: /juice-shop/ftp + - name: i18n + mountPath: /juice-shop/i18n + - name: logs + mountPath: /juice-shop/logs + - name: frontend-dist + mountPath: /juice-shop/frontend/dist + - name: csaf + mountPath: /juice-shop/.well-known/csaf + + volumes: + - name: tmp + emptyDir: {} + - name: data + emptyDir: {} + - name: ftp + emptyDir: {} + - name: i18n + emptyDir: {} + - name: logs + emptyDir: {} + - name: frontend-dist + emptyDir: {} + - name: csaf + emptyDir: {} diff --git a/labs/lab7/k8s/namespace.yaml b/labs/lab7/k8s/namespace.yaml new file mode 100644 index 000000000..67b3c6d44 --- /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 000000000..00488219c --- /dev/null +++ b/labs/lab7/k8s/networkpolicy.yaml @@ -0,0 +1,42 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: juice-shop-restricted + namespace: juice-shop +spec: + podSelector: + matchLabels: + app: juice-shop + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + - ipBlock: + cidr: 127.0.0.1/32 + ports: + - protocol: TCP + port: 3000 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - protocol: TCP + port: 443 diff --git a/labs/lab7/k8s/serviceaccount.yaml b/labs/lab7/k8s/serviceaccount.yaml new file mode 100644 index 000000000..fc24f5a35 --- /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 000000000..d9fdee64e --- /dev/null +++ b/labs/lab7/policies/pod-hardening.rego @@ -0,0 +1,35 @@ +package main + +deny contains msg if { + input.kind == "Deployment" + pod_spec := input.spec.template.spec + pod_security_context := object.get(pod_spec, "securityContext", {}) + object.get(pod_security_context, "runAsNonRoot", false) != true + msg := "pod must set spec.securityContext.runAsNonRoot to true" +} + +deny contains msg if { + input.kind == "Deployment" + some container in input.spec.template.spec.containers + security_context := object.get(container, "securityContext", {}) + object.get(security_context, "readOnlyRootFilesystem", false) != true + msg := sprintf("container %q must set readOnlyRootFilesystem to true", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + some container in input.spec.template.spec.containers + security_context := object.get(container, "securityContext", {}) + object.get(security_context, "allowPrivilegeEscalation", null) != false + msg := sprintf("container %q must set allowPrivilegeEscalation to false", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + some container in input.spec.template.spec.containers + security_context := object.get(container, "securityContext", {}) + capabilities := object.get(security_context, "capabilities", {}) + drop_list := object.get(capabilities, "drop", []) + not "ALL" in drop_list + msg := sprintf("container %q must drop the ALL capability set", [container.name]) +} diff --git a/submissions/lab7.md b/submissions/lab7.md new file mode 100644 index 000000000..09dd3724e --- /dev/null +++ b/submissions/lab7.md @@ -0,0 +1,342 @@ +# Lab 7 — Submission + +## Environment + +The work was executed against a local `kind` cluster on 2 July 2026. + +| Tool | Version | +|---|---:| +| Docker CLI | 27.5.1 | +| Trivy | 0.69.2 | +| kubectl | 1.33.0 | +| kind | 0.29.0 | +| Conftest | 0.68.0 | +| OPA | 1.15.1 | +| jq | 1.7.1 | +| Grype fallback | 0.114.0 | + +The scanned image was pinned to the following immutable multi-platform digest: + +```text +bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0 +``` + +## Task 1: Trivy Image + Config Scan + +### Image scan severity breakdown + +The image scan was restricted to `HIGH` and `CRITICAL` findings. + +| Severity | Total | With fix available | +|---|---:|---:| +| Critical | 5 | 4 | +| High | 43 | 42 | +| **Total** | **48** | **46** | + +A fixed version exists for 46 of the 48 findings. Remediation should therefore begin with fixed critical vulnerabilities and then continue with fixed high-severity vulnerabilities. + +### Top 10 vulnerabilities with fixes + +| CVE/advisory | Severity | Package | Installed | Fix | +|---|---|---|---|---| +| 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-2023-46233 | Critical | crypto-js | 3.3.0 | 4.2.0 | +| CVE-2016-1000223 | High | jws | 0.2.6 | >=3.0.0 | +| CVE-2017-18214 | High | moment | 2.0.0 | 2.19.3 | +| CVE-2018-16487 | High | lodash | 2.4.2 | >=4.17.11 | +| CVE-2020-15084 | High | express-jwt | 0.1.3 | 6.0.0 | +| CVE-2021-23337 | High | lodash | 2.4.2 | 4.17.21 | +| CVE-2022-23539 | High | jsonwebtoken | 0.1.0 | 9.0.0 | + +The repeated `CVE-2015-9235` rows are separate package instances. The image contains vulnerable `jsonwebtoken` versions `0.1.0` and `0.4.0`. + +### Dockerfile config scan + +The intentionally insecure sample Dockerfile was scanned with Trivy's Dockerfile misconfiguration analyzer. Because the file uses the non-standard name `Dockerfile-bad`, the analyzer was selected explicitly with `--file-patterns`: + +```bash +trivy config labs/lab7/results \ + --misconfig-scanners dockerfile \ + --file-patterns 'dockerfile:.*Dockerfile-bad$' \ + --severity HIGH,CRITICAL \ + --format table +``` + +Trivy detected one configuration file and executed 20 checks: + +```text +Target: Dockerfile-bad +Type: dockerfile +Tests: 20 +Successes: 19 +Failures: 1 +High: 1 +Critical: 0 +``` + +The failed rule was: + +```text +DS-0002 (HIGH): Last USER command in Dockerfile should not be 'root' +Dockerfile-bad:2 +2 [ USER root +``` + +Running the container as `root` increases the impact of a container breakout or application compromise. The remediation is to create or select an unprivileged user and make the final effective `USER` instruction reference that account. The other intentionally suspicious lines in this minimal sample were not classified as `HIGH` or `CRITICAL` by Trivy 0.69.2 under the selected severity filter. + +The hardened Kubernetes manifests were also scanned with `trivy config`; all four manifests returned zero high or critical misconfigurations. + +### Compared with Grype + +The previous Lab 4 Grype JSON was not present in the repository, so Grype 0.114.0 was rerun against the same pinned Juice Shop image. The comparison produced 2 common advisory IDs, 34 Trivy-only IDs, and 92 Grype-only IDs. + +#### Finding detected by both tools: CVE-2026-45447 + +Both scanners detected `CVE-2026-45447` in Debian package `libssl3t64` version `3.5.5-1~deb13u2`. Both identified `3.5.6-1~deb13u2` as the fixed version. This is an exact package, installed-version, advisory, and fixed-version match. + +#### Finding detected by Grype but absent from Trivy: CVE-2026-34182 + +Grype reported `CVE-2026-34182` as Critical for `libssl3t64` version `3.5.5-1~deb13u2`, with `3.5.6-1~deb13u2` as the fix. Trivy 0.69.2 did not report this advisory even though it discovered the same package. The divergence is therefore most plausibly caused by advisory-feed freshness or different Debian/OpenSSL advisory normalization rather than package inventory discovery. Grype attached an EPSS value of `0.00237`; EPSS can help prioritize remediation, but it does not control whether a scanner maps a package to a vulnerability. + +## Task 2: Kubernetes Hardening + +### Namespace PSS labels + +```yaml +labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/audit: restricted +``` + +The live namespace reported the enforced profile as: + +```text +restricted +``` + +An intentionally non-compliant Pod was rejected by Pod Security Admission because it lacked `allowPrivilegeEscalation: false`, `capabilities.drop: ["ALL"]`, `runAsNonRoot: true`, and an allowed seccomp profile. + +### Dedicated ServiceAccount + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: juice-shop-sa + namespace: juice-shop +automountServiceAccountToken: false +``` + +### Pod and container security context + +```yaml +spec: + serviceAccountName: juice-shop-sa + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: juice-shop + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 500m + memory: 1Gi +``` + +The image is pinned by digest rather than a mutable tag. The workload also disables automatic ServiceAccount token mounting at both the ServiceAccount and Pod levels. + +### Writable paths with a read-only root filesystem + +Juice Shop modifies several directories at runtime, but those directories also contain seed files shipped in the image. Mounting an empty `emptyDir` directly over them makes the directory writable while hiding the original files. + +The final Deployment therefore uses a hardened init container to copy the image contents into writable `emptyDir` volumes before the main container starts. The main container mounts the prepared volumes over these paths: + +```yaml +volumeMounts: + - name: tmp + mountPath: /tmp + - name: data + mountPath: /juice-shop/data + - name: ftp + mountPath: /juice-shop/ftp + - name: i18n + mountPath: /juice-shop/i18n + - name: logs + mountPath: /juice-shop/logs + - name: frontend-dist + mountPath: /juice-shop/frontend/dist + - name: csaf + mountPath: /juice-shop/.well-known/csaf +``` + +The init container itself also uses `allowPrivilegeEscalation: false`, `readOnlyRootFilesystem: true`, `capabilities.drop: ["ALL"]`, resource limits, and the Pod-level `RuntimeDefault` seccomp profile. + +### NetworkPolicy + +```yaml +spec: + podSelector: + matchLabels: + app: juice-shop + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + - ipBlock: + cidr: 127.0.0.1/32 + ports: + - protocol: TCP + port: 3000 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - protocol: TCP + port: 443 +``` + +The selected Pod is isolated for both ingress and egress. DNS is limited to CoreDNS in `kube-system`, while general outbound connectivity is restricted to TCP port 443. + +### Pod is running + +The corrected Deployment rolled out successfully, and the final Pod was ready with zero restarts: + +```text +NAME READY STATUS RESTARTS AGE IP NODE +juice-shop-68cd775696-7wcq8 1/1 Running 0 4m32s 10.244.0.6 lab7-control-plane +``` + +The compact verification output was: + +```text +NAME READY PHASE RESTARTS +juice-shop-68cd775696-7wcq8 true Running 0 +``` + +Application logs confirmed successful initialization and startup: + +```text +info: Entity models 21 of 21 are initialized (SUCCESS) +info: Port 3000 is available (SUCCESS) +info: Server listening on port 3000 +``` + +### Trivy Kubernetes scan + +The live-cluster scan completed successfully. Because the same image is used by both the init container and the main container, Trivy's workload summary counts each image finding twice. + +| Finding type | Critical | High | +|---|---:|---:| +| Workload aggregate | 10 | 86 | +| Unique image findings | 5 | 43 | +| Kubernetes misconfigurations | 0 | 0 | +| Workload secrets | 0 | 4 | +| Unique image secrets | 0 | 2 | + +The remaining vulnerability and secret findings originate from the deliberately vulnerable application image. No high or critical Kubernetes misconfigurations were reported for the hardened workload. + +### What broke and how it was fixed + +The first read-only-root implementation mounted an empty `emptyDir` directly at `/juice-shop/data`. This hid files already present in the image, including `/juice-shop/data/static/legal.md` and `/juice-shop/data/static/securityQuestions.yml`. Juice Shop then failed during security-question initialization and entered `CrashLoopBackOff`. + +The corrected Deployment performs a copy-up operation in an init container. It copies the original contents of `data`, `ftp`, `i18n`, `logs`, `frontend/dist`, and `.well-known/csaf` into writable `emptyDir` volumes before the main container starts. This preserves the required seed files while keeping the main container's root filesystem read-only. + +## Bonus: Conftest Policy Gate + +### Policy + +```rego +package main + +deny contains msg if { + input.kind == "Deployment" + pod_spec := input.spec.template.spec + pod_security_context := object.get(pod_spec, "securityContext", {}) + object.get(pod_security_context, "runAsNonRoot", false) != true + msg := "pod must set spec.securityContext.runAsNonRoot to true" +} + +deny contains msg if { + input.kind == "Deployment" + some container in input.spec.template.spec.containers + security_context := object.get(container, "securityContext", {}) + object.get(security_context, "readOnlyRootFilesystem", false) != true + msg := sprintf("container %q must set readOnlyRootFilesystem to true", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + some container in input.spec.template.spec.containers + security_context := object.get(container, "securityContext", {}) + object.get(security_context, "allowPrivilegeEscalation", null) != false + msg := sprintf("container %q must set allowPrivilegeEscalation to false", [container.name]) +} + +deny contains msg if { + input.kind == "Deployment" + some container in input.spec.template.spec.containers + security_context := object.get(container, "securityContext", {}) + capabilities := object.get(security_context, "capabilities", {}) + drop_list := object.get(capabilities, "drop", []) + not "ALL" in drop_list + msg := sprintf("container %q must drop the ALL capability set", [container.name]) +} +``` + +### PASS on the hardened manifest + +```text +4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions +``` + +### FAIL on an intentionally insecure Deployment + +```text +FAIL - container "app" must drop the ALL capability set +FAIL - container "app" must set allowPrivilegeEscalation to false +FAIL - container "app" must set readOnlyRootFilesystem to true +FAIL - pod must set spec.securityContext.runAsNonRoot to true + +4 tests, 0 passed, 0 warnings, 4 failures, 0 exceptions +``` + +### What this prevents at CI time + +The policy rejects insecure pod-template configuration before `kubectl apply`: root-capable execution, writable container root filesystems, privilege escalation, and retained Linux capabilities. CI-time enforcement gives immediate feedback in the pull request and prevents a known-bad manifest from reaching cluster admission control, reducing deployment churn and limiting reliance on consistent admission-policy configuration across clusters. +