Skip to content

Commit 050744f

Browse files
committed
build: ship a scanned non-root jlink runtime
1 parent dcf7404 commit 050744f

10 files changed

Lines changed: 109 additions & 150 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -11,40 +11,6 @@ permissions:
1111
contents: read
1212

1313
jobs:
14-
backend-test:
15-
name: Backend Tests
16-
runs-on: truenas-website
17-
timeout-minutes: 30
18-
19-
steps:
20-
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
21-
22-
- name: Set up JDK 21
23-
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
24-
with:
25-
java-version: '21'
26-
distribution: temurin
27-
cache: maven
28-
29-
- name: Set up Node.js 24
30-
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
31-
with:
32-
node-version: '24'
33-
cache: npm
34-
cache-dependency-path: frontend/package-lock.json
35-
36-
- name: Run Backend Tests
37-
run: ./mvnw --batch-mode test
38-
39-
- name: Upload Test Results
40-
if: always()
41-
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
42-
with:
43-
name: backend-test-results
44-
path: target/surefire-reports/
45-
if-no-files-found: ignore
46-
retention-days: 7
47-
4814
frontend-build:
4915
name: Frontend Build
5016
runs-on: truenas-website
@@ -53,17 +19,21 @@ jobs:
5319
steps:
5420
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
5521

56-
- name: Set up Node.js 24
22+
- name: Set up Node.js 26.10.0
5723
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
5824
with:
59-
node-version: '24'
25+
node-version: '26.10.0'
6026
cache: npm
6127
cache-dependency-path: frontend/package-lock.json
6228

6329
- name: Install Dependencies
6430
working-directory: ./frontend
6531
run: npm ci
6632

33+
- name: Audit Frontend Dependencies
34+
working-directory: ./frontend
35+
run: npm audit --audit-level=low
36+
6737
- name: Build Frontend
6838
working-directory: ./frontend
6939
run: npm run build
@@ -77,9 +47,9 @@ jobs:
7747
retention-days: 7
7848

7949
docker-build:
80-
name: Docker Build
50+
name: Backend Tests, Runtime Smoke and Vulnerability Scan
8151
runs-on: truenas-website
82-
needs: [backend-test, frontend-build]
52+
needs: [frontend-build]
8353
timeout-minutes: 40
8454

8555
steps:
@@ -95,14 +65,14 @@ jobs:
9565
cert=/run/buildkit/tls/client.pem
9666
key=/run/buildkit/tls/client.key
9767
98-
- name: Build and Smoke Test Runtime Image
68+
- name: Test Backend, Verify Linked Runtime and Scan Vulnerabilities
9969
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
10070
with:
10171
context: .
10272
file: ./Dockerfile
103-
target: smoke-test
73+
target: security-scan
10474
platforms: linux/amd64
10575
push: false
10676
cache-from: type=gha,version=2,scope=truenas-15ed4a3293d99fba
10777
cache-to: type=gha,version=2,mode=max,scope=truenas-15ed4a3293d99fba
108-
no-cache-filters: smoke-test
78+
no-cache-filters: smoke-test,security-scan

‎.github/workflows/deploy.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ jobs:
7979
context: .
8080
file: ./Dockerfile
8181
target: runtime
82+
no-cache-filters: smoke-test,security-scan
8283
push: true
8384
tags: ${{ steps.meta.outputs.tags }}
8485
labels: ${{ steps.meta.outputs.labels }}

‎.nvmrc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
24
1+
26.10.0

‎Dockerfile‎

Lines changed: 61 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,92 @@
1-
# Maven's resource lifecycle builds the Vite assets, so provide both toolchains
2-
# to the builder while keeping the runtime image limited to the JRE.
3-
FROM node:24-alpine AS node-toolchain
4-
5-
FROM maven:3.9.16-eclipse-temurin-21-alpine AS backend-build
1+
# syntax=docker/dockerfile:1
2+
# Java and Node are build tools only; the published image contains a jlink runtime.
3+
FROM node:26.10.0-alpine@sha256:0b36e8c136b94cd4fcf02188228e76c31ad5872eef3fec8cbd2eee500cfd9e80 AS node-toolchain
64

5+
FROM eclipse-temurin:25.0.4.1_1-jdk-alpine-3.24@sha256:3fd2d245c4e0eba615fe366a71b8bd25f5db7104f53e4026b24bf508b880bd2a AS backend-build
76
RUN apk add --no-cache libstdc++
87
COPY --from=node-toolchain /usr/local/ /usr/local/
9-
108
WORKDIR /app
11-
12-
COPY pom.xml ./
13-
COPY mvnw ./
9+
COPY pom.xml mvnw ./
1410
COPY .mvn ./.mvn
1511
COPY frontend ./frontend
1612
COPY src ./src
1713

18-
# The Maven lifecycle runs npm ci and the frontend build before packaging.
19-
RUN ./mvnw --batch-mode clean package -DskipTests
20-
21-
# Stage 3: Runtime
22-
FROM eclipse-temurin:21-jre-alpine AS runtime-base
23-
24-
WORKDIR /app
25-
26-
# Create non-root user
27-
RUN addgroup -g 1001 -S spring && \
14+
# Run the same backend tests and frontend checks used for local verification.
15+
RUN ./mvnw --batch-mode --no-transfer-progress clean verify
16+
17+
FROM backend-build AS java-runtime
18+
# jdeps finds static JDK dependencies. Reflection, charset and service-provider
19+
# use in Spring/Hibernate/Netty/PDFBox needs the additional modules below.
20+
RUN mkdir /app/unpacked && cd /app/unpacked && \
21+
jar -xf /app/target/website-0.0.1-SNAPSHOT.jar && \
22+
modules="$(jdeps --ignore-missing-deps --multi-release 25 --recursive \
23+
--print-module-deps --class-path 'BOOT-INF/lib/*' BOOT-INF/classes)" && \
24+
jlink --add-modules "$modules,java.desktop,java.instrument,java.management,java.naming,java.sql,jdk.charsets,jdk.crypto.ec,jdk.localedata,jdk.unsupported,jdk.zipfs" \
25+
--strip-debug --no-header-files --no-man-pages --compress=zip-6 \
26+
--output /opt/java-runtime
27+
28+
FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 AS runtime-base
29+
RUN apk upgrade --no-cache && \
30+
apk add --no-cache ca-certificates libstdc++ zlib fontconfig font-dejavu && \
31+
addgroup -g 1001 -S spring && \
2832
adduser -u 1001 -S spring -G spring
29-
30-
# Copy JAR from build stage
31-
COPY --from=backend-build /app/target/*.jar app.jar
32-
33-
# Copy resources directory (for markdown files)
34-
COPY --from=backend-build /app/src/main/resources ./resources
35-
36-
# Change ownership
37-
RUN chown -R spring:spring /app
38-
39-
# Switch to non-root user
40-
USER spring:spring
41-
42-
# Expose port
33+
ENV JAVA_HOME=/opt/java
34+
ENV PATH="$JAVA_HOME/bin:$PATH" \
35+
JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Djava.awt.headless=true"
36+
WORKDIR /app
37+
COPY --from=java-runtime /opt/java-runtime /opt/java
38+
COPY --from=backend-build --chmod=0444 /app/target/website-0.0.1-SNAPSHOT.jar /app/app.jar
39+
USER 1001:1001
4340
EXPOSE 8080
44-
45-
# Actuator exposes the application's health status without relying on a
46-
# client-information API as a readiness signal.
4741
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
48-
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
49-
50-
# Set JVM options
51-
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
42+
CMD wget -q -T 2 -O /dev/null http://127.0.0.1:8080/actuator/health || exit 1
43+
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
5244

53-
# Run application
54-
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
55-
56-
# BuildKit runs this stage as the same non-root runtime user. Starting the
57-
# packaged application here verifies the runtime filesystem without needing a
58-
# Docker daemon or exporting an image from the remote builder.
45+
# Run the packaged app as the production UID to verify the linked Java modules,
46+
# PDF extraction, content parsing, and the actual generated web assets.
5947
FROM runtime-base AS smoke-test
6048
RUN set -eu; \
61-
java $JAVA_OPTS -jar app.jar > /tmp/website-smoke.log 2>&1 & \
49+
test "$(id -u)" = 1001; \
50+
test ! -e /opt/java/bin/javac; \
51+
java -version; \
52+
java $JAVA_OPTS -jar /app/app.jar > /tmp/website-smoke.log 2>&1 & \
6253
app_pid=$!; \
6354
cleanup() { kill "$app_pid" 2>/dev/null || true; wait "$app_pid" 2>/dev/null || true; }; \
6455
trap cleanup EXIT; \
6556
attempt=0; \
6657
while [ "$attempt" -lt 60 ]; do \
6758
if ! kill -0 "$app_pid" 2>/dev/null; then cat /tmp/website-smoke.log; exit 1; fi; \
68-
if wget -T 5 -qO /tmp/website-health.json http://127.0.0.1:8080/actuator/health; then break; fi; \
59+
if wget -T 5 -qO /tmp/website-health.json http://127.0.0.1:8080/actuator/health; then break; fi; \
6960
attempt=$((attempt + 1)); \
7061
sleep 2; \
7162
done; \
7263
if [ "$attempt" -ge 60 ]; then cat /tmp/website-smoke.log; exit 1; fi; \
73-
if ! grep -Eq '"status"[[:space:]]*:[[:space:]]*"UP"' /tmp/website-health.json; then cat /tmp/website-health.json; exit 1; fi; \
74-
if ! wget -T 5 -qO /tmp/website-client-info.json http://127.0.0.1:8080/api/client/info; then cat /tmp/website-smoke.log; exit 1; fi; \
75-
grep -Eq '"hostname"[[:space:]]*:[[:space:]]*"javadevjt\.tech"' /tmp/website-client-info.json; \
64+
grep -Eq '"status"[[:space:]]*:[[:space:]]*"UP"' /tmp/website-health.json; \
65+
wget -T 5 -qO /tmp/website-client-info.json http://127.0.0.1:8080/api/client/info; \
66+
grep -Eq '"hostname"[[:space:]]*:[[:space:]]*"javadevjt[.]tech"' /tmp/website-client-info.json; \
7667
grep -Eq '"username"[[:space:]]*:[[:space:]]*"visitor"' /tmp/website-client-info.json; \
77-
if ! wget -T 5 -qO /tmp/website-portfolio.json http://127.0.0.1:8080/api/content/portfolio/list; then cat /tmp/website-smoke.log; exit 1; fi; \
78-
if ! grep -Eq '"filename"|RouteListToTesla' /tmp/website-portfolio.json; then cat /tmp/website-portfolio.json; exit 1; fi; \
79-
if ! wget -T 5 -qO /tmp/website-index.html http://127.0.0.1:8080/; then cat /tmp/website-smoke.log; exit 1; fi; \
80-
js_asset="$(grep -oE 'src="/assets/[^"]+\.js"' /tmp/website-index.html | head -n 1 | cut -d '"' -f 2)"; \
81-
css_asset="$(grep -oE 'href="/assets/[^"]+\.css"' /tmp/website-index.html | head -n 1 | cut -d '"' -f 2)"; \
68+
wget -T 5 -qO /tmp/website-portfolio.json http://127.0.0.1:8080/api/content/portfolio/list; \
69+
grep -q 'Discord Option Tailer' /tmp/website-portfolio.json; \
70+
grep -q 'Embedify' /tmp/website-portfolio.json; \
71+
wget -T 5 -qO /tmp/website-resume.json http://127.0.0.1:8080/api/content/resume; \
72+
grep -q 'Senior Software Engineer' /tmp/website-resume.json; \
73+
wget -T 5 -qO /tmp/website-index.html http://127.0.0.1:8080/; \
74+
js_asset="$(grep -oE 'src="/assets/[^"]+[.]js"' /tmp/website-index.html | head -n 1 | cut -d '"' -f 2)"; \
75+
css_asset="$(grep -oE 'href="/assets/[^"]+[.]css"' /tmp/website-index.html | head -n 1 | cut -d '"' -f 2)"; \
8276
test -n "$js_asset" && test -n "$css_asset"; \
8377
wget -T 5 -qO /tmp/website-app.js "http://127.0.0.1:8080$js_asset"; \
8478
wget -T 5 -qO /tmp/website-app.css "http://127.0.0.1:8080$css_asset"; \
8579
test -s /tmp/website-app.js && test -s /tmp/website-app.css
8680

87-
# Keep normal image builds on the production runtime stage; CI explicitly
88-
# selects smoke-test to execute the verification above.
81+
# Scan the runtime filesystem, including OS packages and nested Java libraries.
82+
# CI always reruns this stage against fresh vulnerability data.
83+
FROM aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 AS security-scan
84+
COPY --from=smoke-test / /scan-root/
85+
RUN trivy rootfs --scanners vuln --exit-code 1 --timeout 10m /scan-root && touch /scan-passed
86+
87+
# Default builds publish only the application and linked runtime.
8988
FROM runtime-base AS runtime
89+
# This read-only build mount forces publication to depend on a scan of this
90+
# exact runtime-base filesystem without shipping the scanner or scan files.
91+
RUN --mount=type=bind,from=security-scan,source=/scan-passed,target=/scan-passed \
92+
test -f /scan-passed

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ The [maintenance audit](docs/maintenance-audit-2026-09-28.md) records the code r
66

77
## Requirements
88

9-
- Java 21, with `JAVA_HOME` pointing to that installation.
10-
- Node.js 24 LTS and npm; `.nvmrc` selects the supported Node major.
9+
- Java 25, with `JAVA_HOME` pointing to that installation.
10+
- Node.js 26.10.0 and npm; `.nvmrc` selects the supported version.
1111
- The checked-in Maven wrapper downloads the project's Maven distribution.
1212

1313
## Build and test
@@ -45,7 +45,9 @@ Vite serves the development UI on port 3000. `npm run build` runs TypeScript che
4545

4646
[CI](.github/workflows/ci.yml) and [deployment configuration](.github/workflows/deploy.yml) contain the automated build and image-publication rules. See the [maintenance audit](docs/maintenance-audit-2026-09-28.md) for the private-runner contract, validation evidence, and activation boundaries.
4747

48-
The [Dockerfile](Dockerfile) builds the frontend and Spring Boot application. Container and reverse-proxy settings live under [infrastructure](infrastructure/). Verify the locally installed Docker or Podman Compose provider before running its commands.
48+
The [Dockerfile](Dockerfile) tests and builds with Java 25 and Node 26, then uses `jdeps` and `jlink` to create the Java runtime. The final Alpine image contains the application and linked runtime, runs as UID/GID 1001, and excludes Maven, Node, Java compiler tools, and source resources outside the JAR. CI boots that image, exercises PDF/content/UI routes, and scans its filesystem with Trivy before image publication. Any reported OS or Java-library vulnerability fails the scan; the scan uses current advisory data rather than guaranteeing future vulnerability absence.
49+
50+
Container and reverse-proxy settings live under [infrastructure](infrastructure/). Verify the locally installed Docker or Podman Compose provider before running its commands.
4951

5052
## Contributing
5153

‎docs/maintenance-audit-2026-09-28.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
This records the pre-release audit and local verification. The owner subsequently authorized adding Discord Option Tailer and Embedify, publishing the changes, and deploying the resulting image; release execution is tracked separately.
44

5+
## Release hardening follow-up
6+
7+
After Adoptium's version-discovery API timed out during CI, Java compilation and tests were moved into the pinned Docker builder. The current source uses Java 25 LTS, Node 26.10.0, Spring Boot 4.1.1, Exec Maven Plugin 3.6.4, and React Router 8.4.0. Java 27 is outside stable Spring Boot's documented compatibility range; Java 25 is the maintained LTS choice within that range.
8+
9+
The multi-stage image now derives its Java modules with `jdeps` and creates a reduced runtime with `jlink`. The final Alpine image runs as UID/GID 1001 and contains the linked runtime and application JAR, without the Node/Maven build tools. CI exercises the linked runtime, PDF text extraction, selected catalogue entries and generated assets, then scans runtime OS packages and Java libraries with Trivy. Frontend dependencies have a separate npm audit gate. These gates fail on known findings and do not claim to eliminate every possible vulnerability. The detailed baseline audit and its earlier local results follow.
10+
511
## Scope and acceptance
612

713
Review all maintained backend, frontend, content, build, and deployment sources; update dependencies using verified stable releases; make the existing GitHub Actions build compatible with the user's private runner; report projects missing from the catalogue without adding them; update current job-title references to Senior Software Engineer while retaining job function.

0 commit comments

Comments
 (0)