Skip to content

Commit 0ceb977

Browse files
committed
feat(lab7): trivy + PSS restricted + conftest gate
1 parent 2f54b0e commit 0ceb977

6 files changed

Lines changed: 286 additions & 0 deletions

File tree

labs/lab7/k8s/deployment.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
2+
apiVersion: apps/v1
3+
kind: Deployment
4+
metadata:
5+
name: juice-shop
6+
namespace: juice-shop
7+
spec:
8+
selector:
9+
matchLabels:
10+
app: juice-shop
11+
template:
12+
metadata:
13+
labels:
14+
app: juice-shop
15+
spec:
16+
serviceAccountName: juice-shop-sa
17+
automountServiceAccountToken: false
18+
securityContext:
19+
runAsNonRoot: true
20+
runAsUser: 1000
21+
fsGroup: 1000
22+
seccompProfile:
23+
type: RuntimeDefault
24+
containers:
25+
- name: app
26+
image: bkimminich/juice-shop@sha256:fd58bdc9745416afce8184ee0666278a436574633ea7880365153a63bfd418b0
27+
resources:
28+
limits:
29+
memory: "512Mi"
30+
cpu: "500m"
31+
requests:
32+
memory: "256Mi"
33+
cpu: "200m"
34+
securityContext:
35+
allowPrivilegeEscalation: false
36+
readOnlyRootFilesystem: true
37+
capabilities:
38+
drop:
39+
- ALL
40+
volumeMounts:
41+
- name: tmp-volume
42+
mountPath: /tmp
43+
- name: logs-volume
44+
mountPath: /juice-shop/logs
45+
volumes:
46+
- name: tmp-volume
47+
emptyDir: {}
48+
- name: logs-volume
49+
emptyDir: {}

labs/lab7/k8s/namespace.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: v1
2+
kind: Namespace
3+
metadata:
4+
name: juice-shop
5+
labels:
6+
pod-security.kubernetes.io/enforce: restricted
7+
pod-security.kubernetes.io/warn: restricted
8+
pod-security.kubernetes.io/audit: restricted

labs/lab7/k8s/networkpolicy.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
apiVersion: networking.k8s.io/v1
2+
kind: NetworkPolicy
3+
metadata:
4+
name: juice-shop-netpol
5+
namespace: juice-shop
6+
spec:
7+
podSelector:
8+
matchLabels:
9+
app: juice-shop
10+
policyTypes:
11+
- Ingress
12+
- Egress
13+
ingress:
14+
- {} # Allows incoming connections (e.g. for kubectl port-forward)
15+
egress:
16+
- to:
17+
- namespaceSelector:
18+
matchLabels:
19+
kubernetes.io/metadata.name: kube-system
20+
ports:
21+
- protocol: UDP
22+
port: 53
23+
- ports:
24+
- protocol: TCP
25+
port: 443

labs/lab7/k8s/serviceaccount.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
apiVersion: v1
2+
kind: ServiceAccount
3+
metadata:
4+
name: juice-shop-sa
5+
namespace: juice-shop
6+
automountServiceAccountToken: false
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
import rego.v1
3+
4+
# 1. spec.securityContext.runAsNonRoot != true
5+
deny contains msg if {
6+
input.kind == "Deployment"
7+
pod_spec := input.spec.template.spec
8+
not pod_spec.securityContext.runAsNonRoot == true
9+
msg := "Pod securityContext must have runAsNonRoot set to true"
10+
}
11+
12+
# 2. (any container) spec.containers[_].securityContext.readOnlyRootFilesystem != true
13+
deny contains msg if {
14+
input.kind == "Deployment"
15+
container := input.spec.template.spec.containers[_]
16+
not container.securityContext.readOnlyRootFilesystem == true
17+
msg := sprintf("Container '%v' must have readOnlyRootFilesystem set to true", [container.name])
18+
}
19+
20+
# 3. (any container) spec.containers[_].securityContext.allowPrivilegeEscalation != false
21+
deny contains msg if {
22+
input.kind == "Deployment"
23+
container := input.spec.template.spec.containers[_]
24+
not container.securityContext.allowPrivilegeEscalation == false
25+
msg := sprintf("Container '%v' must have allowPrivilegeEscalation set to false", [container.name])
26+
}
27+
28+
# 4. (any container) spec.containers[_].securityContext.capabilities.drop missing "ALL"
29+
deny contains msg if {
30+
input.kind == "Deployment"
31+
container := input.spec.template.spec.containers[_]
32+
not has_drop_all(container)
33+
msg := sprintf("Container '%v' must drop ALL capabilities", [container.name])
34+
}
35+
36+
has_drop_all(container) if {
37+
"ALL" in container.securityContext.capabilities.drop
38+
}

submissions/lab7.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Lab 7 — Submission
2+
3+
## Task 1: Trivy Image + Config Scan
4+
5+
### Image scan severity breakdown
6+
| Severity | Total | With fix available |
7+
|----------|------:|------------------:|
8+
| Critical | 5 | 4 |
9+
| High | 43 | 42 |
10+
| **Total** | 48 | 46 |
11+
12+
### Top 10 CVEs with fixes
13+
| CVE | Severity | Package | Installed | Fix |
14+
|-----|----------|---------|-----------|-----|
15+
| CVE-2023-46233 | CRITICAL | crypto-js | 3.3.0 | 4.2.0 |
16+
| CVE-2015-9235 | CRITICAL | jsonwebtoken | 0.1.0 | 4.2.2 |
17+
| CVE-2015-9235 | CRITICAL | jsonwebtoken | 0.4.0 | 4.2.2 |
18+
| CVE-2019-10744 | CRITICAL | lodash | 2.4.2 | 4.17.12 |
19+
| CVE-2026-45447 | HIGH | libssl3t64 | 3.5.5-1~deb13u2 | 3.5.6-1~deb13u2 |
20+
| NSWG-ECO-428 | HIGH | base64url | 0.0.6 | >=3.0.0 |
21+
| CVE-2020-15084 | HIGH | express-jwt | 0.1.3 | 6.0.0 |
22+
| CVE-2022-25881 | HIGH | http-cache-semantics | 3.8.1 | 4.1.1 |
23+
| CVE-2022-23539 | HIGH | jsonwebtoken | 0.1.0 | 9.0.0 |
24+
| NSWG-ECO-17 | HIGH | jsonwebtoken | 0.1.0 | >=4.2.2 |
25+
26+
> **Note regarding lab instructions error:**
27+
> The `jq` command provided in step 7.3 of the lab instructions (`{cve: .VulnerabilityID, severity: .Severity, pkg: .PkgName, fix: .FixedVersion}`) omits the `InstalledVersion` field, making it impossible to fill out the 'Installed' column in the table above using only the provided command. The command had to be manually modified to include `installed: .InstalledVersion` to complete the assignment properly.
28+
29+
30+
### Compared to Lab 4's Grype scan
31+
1. **Found by both tools (CVE-2015-9235 / GHSA-c7hr-j4mj-j2w6 in `jsonwebtoken`)**: Both tools identified the vulnerability, but they represent it differently. Trivy outputs the standard `CVE-2015-9235`, whereas Grype prefers ecosystem-specific identifiers and outputs `GHSA-c7hr-j4mj-j2w6`. This illustrates that the tools use different identifier preferences and package matching rules (Grype favors GitHub Security Advisories for npm).
32+
2. **Found by Trivy, missed by Grype (CVE-2025-57349 in `messageformat`)**: This recent vulnerability was flagged by Trivy but missed by Grype. This difference is primarily due to DB freshness and update cadence. Trivy's vulnerability database is likely updated more frequently, allowing it to ingest newer CVEs faster than the default Anchore snapshot used by Grype.
33+
34+
35+
36+
37+
## Task 2: Kubernetes Hardening
38+
39+
### Manifests (paste relevant snippets)
40+
- `namespace.yaml` PSS labels:
41+
```yaml
42+
pod-security.kubernetes.io/enforce: restricted
43+
pod-security.kubernetes.io/warn: restricted
44+
pod-security.kubernetes.io/audit: restricted
45+
```
46+
- `deployment.yaml` securityContext sections (pod + container):
47+
```yaml
48+
securityContext:
49+
runAsNonRoot: true
50+
runAsUser: 1000
51+
fsGroup: 1000
52+
seccompProfile:
53+
type: RuntimeDefault
54+
...
55+
securityContext:
56+
allowPrivilegeEscalation: false
57+
readOnlyRootFilesystem: true
58+
capabilities:
59+
drop:
60+
- ALL
61+
```
62+
- `networkpolicy.yaml` ingress + egress:
63+
```yaml
64+
ingress:
65+
- {} # Allows incoming connections (e.g. for kubectl port-forward)
66+
egress:
67+
- to:
68+
- namespaceSelector:
69+
matchLabels:
70+
kubernetes.io/metadata.name: kube-system
71+
ports:
72+
- protocol: UDP
73+
port: 53
74+
- ports:
75+
- protocol: TCP
76+
port: 443
77+
```
78+
79+
### Pod is running
80+
Output of `kubectl get pod -n juice-shop -l app=juice-shop`:
81+
```
82+
NAME READY STATUS RESTARTS AGE
83+
juice-shop-654c7d67fb-2hzs4 1/1 Running 0 42s
84+
```
85+
> **Note:** The pod successfully passes the `kubectl wait` condition because it initially starts without crashing, but shortly after goes into a `CrashLoopBackOff` state.
86+
87+
### Trivy K8s scan
88+
> **Note regarding lab instructions error:** The command `trivy k8s --namespace juice-shop` is deprecated. I used `trivy k8s --include-namespaces juice-shop`.
89+
90+
| Severity | Count |
91+
|----------|------:|
92+
| Critical | 5 |
93+
| High | 43 |
94+
95+
### What broke and how you fixed it (2-3 sentences)
96+
`readOnlyRootFilesystem: true` breaks Juice Shop because it needs to write to `/tmp` and `/juice-shop/logs`, as well as its SQLite database and FTP directory. I fixed the initial crash by mounting `emptyDir` volumes at `/tmp` and `/juice-shop/logs` (although v20.0.0 also requires writable access to `/juice-shop/data`, but simply mounting an `emptyDir` there hides the required `static` directory unless advanced K8s patterns like initContainers are used).
97+
98+
99+
100+
## Bonus: Conftest Policy
101+
102+
### Policy (paste labs/lab7/policies/pod-hardening.rego)
103+
```rego
104+
package main
105+
import rego.v1
106+
107+
# 1. spec.securityContext.runAsNonRoot != true
108+
deny contains msg if {
109+
input.kind == "Deployment"
110+
pod_spec := input.spec.template.spec
111+
not pod_spec.securityContext.runAsNonRoot == true
112+
msg := "Pod securityContext must have runAsNonRoot set to true"
113+
}
114+
115+
# 2. (any container) spec.containers[_].securityContext.readOnlyRootFilesystem != true
116+
deny contains msg if {
117+
input.kind == "Deployment"
118+
container := input.spec.template.spec.containers[_]
119+
not container.securityContext.readOnlyRootFilesystem == true
120+
msg := sprintf("Container '%v' must have readOnlyRootFilesystem set to true", [container.name])
121+
}
122+
123+
# 3. (any container) spec.containers[_].securityContext.allowPrivilegeEscalation != false
124+
deny contains msg if {
125+
input.kind == "Deployment"
126+
container := input.spec.template.spec.containers[_]
127+
not container.securityContext.allowPrivilegeEscalation == false
128+
msg := sprintf("Container '%v' must have allowPrivilegeEscalation set to false", [container.name])
129+
}
130+
131+
# 4. (any container) spec.containers[_].securityContext.capabilities.drop missing "ALL"
132+
deny contains msg if {
133+
input.kind == "Deployment"
134+
container := input.spec.template.spec.containers[_]
135+
not has_drop_all(container)
136+
msg := sprintf("Container '%v' must drop ALL capabilities", [container.name])
137+
}
138+
139+
has_drop_all(container) if {
140+
"ALL" in container.securityContext.capabilities.drop
141+
}
142+
```
143+
144+
### Output: PASS on hardened manifest
145+
```
146+
4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions
147+
```
148+
149+
### Output: FAIL on bad manifest
150+
```
151+
FAIL - /tmp/bad-pod.yaml - main - Container 'app' must drop ALL capabilities
152+
FAIL - /tmp/bad-pod.yaml - main - Container 'app' must have allowPrivilegeEscalation set to false
153+
FAIL - /tmp/bad-pod.yaml - main - Container 'app' must have readOnlyRootFilesystem set to true
154+
FAIL - /tmp/bad-pod.yaml - main - Pod securityContext must have runAsNonRoot set to true
155+
156+
4 tests, 0 passed, 0 warnings, 4 failures, 0 exceptions
157+
```
158+
159+
### What this prevents at CI time (2-3 sentences)
160+
This policy catches insecure configuration bugs (like running as root or allowing privilege escalation) natively in the CI pipeline BEFORE `kubectl apply` is ever executed. By failing the pipeline early, developers get immediate feedback in their Pull Requests without waiting for the cluster's admission controller (like PSS) to reject the deployment. This represents true "shift-left" security, reducing friction and keeping the cluster entirely unaware of bad manifests.

0 commit comments

Comments
 (0)