From 16d59aa9e7c4d4f48eb69acfa023cc4c083f22e3 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:52:42 +0100 Subject: [PATCH] chore: harden webapp docker image (#3845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the webapp Docker image and adds a CVE scan of each published image. - Base image `bullseye-slim` → `bookworm-slim` (Debian 12), pinned by digest. Adds `apt-get upgrade` + `--no-install-recommends` + apt-cache cleanup across the build stages so OS packages are patched at build time. - Moves the `react-email` CLI to `devDependencies` in `internal-packages/emails` — only the `email dev` preview script uses it; the runtime render path is `@react-email/render` + `@react-email/components`. This also drops the bundled `esbuild` binary from the production image. - Bumps `goose` v3.26.0 → v3.27.1 and its Go builder image 1.23 → 1.26. - Adds a reusable Trivy image-scan workflow wired into `publish.yml`, so every published image (main builds and releases) is scanned for OS-package CVEs right after it's pushed to GHCR. Report-only (writes to the run summary), runs alongside the worker publishes so it never blocks a deploy. Verified locally: the image builds clean on the new base, and `@react-email/render` carries no `esbuild` dependency so email rendering is unaffected. --- .github/workflows/publish-webapp.yml | 7 +++ .github/workflows/publish.yml | 11 ++++ .github/workflows/trivy-image-webapp.yml | 75 ++++++++++++++++++++++++ docker/Dockerfile | 12 ++-- internal-packages/emails/package.json | 4 +- pnpm-lock.yaml | 6 +- 6 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/trivy-image-webapp.yml diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml index 377c994f6..399ed321b 100644 --- a/.github/workflows/publish-webapp.yml +++ b/.github/workflows/publish-webapp.yml @@ -14,6 +14,13 @@ on: type: string required: false default: "" + outputs: + version: + description: The published image tag + value: ${{ jobs.publish.outputs.version }} + short_sha: + description: Short commit SHA of the published build + value: ${{ jobs.publish.outputs.short_sha }} secrets: SENTRY_AUTH_TOKEN: required: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a238395c8..64b0f11ea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -96,3 +96,14 @@ jobs: uses: ./.github/workflows/publish-worker-v4.yml with: image_tag: ${{ inputs.image_tag }} + + # OS-level CVE scan of the image just published above. Report-only (writes to + # the run summary); runs alongside the worker publishes and never blocks them. + scan-webapp: + needs: [publish-webapp] + permissions: + contents: read + packages: read # pull the just-published image from GHCR + uses: ./.github/workflows/trivy-image-webapp.yml + with: + image-ref: ghcr.io/triggerdotdev/trigger.dev:${{ needs.publish-webapp.outputs.version }} diff --git a/.github/workflows/trivy-image-webapp.yml b/.github/workflows/trivy-image-webapp.yml new file mode 100644 index 000000000..7dae65ef2 --- /dev/null +++ b/.github/workflows/trivy-image-webapp.yml @@ -0,0 +1,75 @@ +name: Trivy Image Scan (webapp) + +# OS-level CVE scan of a published webapp image. Called by the publish pipeline +# (publish.yml) to scan each build right after it's pushed to GHCR — so every +# main build and every release is scanned, not rebuilt. Also runnable ad-hoc +# via workflow_dispatch against any image ref. +# +# Report-only: writes a table to the run summary. No SARIF upload, no gate. +# Library/dependency CVEs are covered by Dependabot, so this is restricted to +# OS packages (`vuln-type: os`) to avoid double-reporting. + +on: + workflow_call: + inputs: + image-ref: + description: "Full image ref to scan (e.g. ghcr.io/triggerdotdev/trigger.dev:main)" + type: string + required: true + workflow_dispatch: + inputs: + image-ref: + description: "Full image ref to scan" + type: string + required: false + default: "ghcr.io/triggerdotdev/trigger.dev:main" + +permissions: {} + +concurrency: + group: trivy-image-webapp-${{ inputs.image-ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan + runs-on: ubuntu-latest + permissions: + contents: read + packages: read # pull the image from GHCR + steps: + # Authenticate to GHCR so the scan also works for private images + # (GITHUB_TOKEN isn't forwarded to Docker automatically). Harmless for + # public images. Pairs with the packages: read permission above. + - name: Log in to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run Trivy image scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: image + image-ref: ${{ inputs.image-ref }} + # vuln-type maps to --pkg-types: OS packages only (library deps are + # Dependabot's job). ignore-unfixed drops vulns with no patch yet. + vuln-type: os + ignore-unfixed: true + severity: HIGH,CRITICAL + format: table + output: trivy-image-webapp.txt + + - name: Job summary + if: always() + env: + IMAGE_REF: ${{ inputs.image-ref }} + run: | + { + echo "## Trivy Image Scan (webapp) — \`${IMAGE_REF}\`" + echo '```' + # GitHub step summary is capped at 1 MiB; truncate large reports. + head -c 900000 trivy-image-webapp.txt 2>/dev/null || echo "(no report produced)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docker/Dockerfile b/docker/Dockerfile index c075d3f80..81fe4afe3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,7 @@ -ARG NODE_IMAGE=node:20.20.2-bullseye-slim@sha256:65ef49f7d24aefd012a7fc6f9a2b734bcc19e424976a81f60c86b47266ef5b28 +ARG NODE_IMAGE=node:20.20.2-bookworm-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0 -FROM golang:1.23-alpine AS goose_builder -RUN go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 +FROM golang:1.26-alpine AS goose_builder +RUN go install github.com/pressly/goose/v3/cmd/goose@v3.27.1 FROM ${NODE_IMAGE} AS pruner @@ -13,7 +13,7 @@ RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + # Base strategy to have layer caching FROM ${NODE_IMAGE} AS base -RUN apt-get update && apt-get install -y openssl dumb-init +RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends openssl dumb-init && rm -rf /var/lib/apt/lists/* WORKDIR /triggerdotdev COPY --chown=node:node .gitignore .gitignore COPY --from=pruner --chown=node:node /triggerdotdev/out/json/ . @@ -43,7 +43,7 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install ## Builder (builds the webapp) FROM base AS builder # This is needed for the sentry-cli binary while building the webapp -RUN apt-get update && apt-get install -y openssl dumb-init ca-certificates +RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends openssl dumb-init ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /triggerdotdev # Corepack is used to install pnpm with the exact version from packageManager RUN corepack enable && corepack prepare pnpm@10.33.2 --activate @@ -75,7 +75,7 @@ RUN --mount=type=secret,id=sentry_auth_token \ # Runner FROM ${NODE_IMAGE} AS runner -RUN apt-get update && apt-get install -y openssl netcat-openbsd ca-certificates +RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends openssl netcat-openbsd ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /triggerdotdev ENV NODE_ENV=production diff --git a/internal-packages/emails/package.json b/internal-packages/emails/package.json index e4f8d20d9..a3091eb97 100644 --- a/internal-packages/emails/package.json +++ b/internal-packages/emails/package.json @@ -16,7 +16,6 @@ "nodemailer": "^8.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-email": "^6.5.0", "resend": "^3.2.0", "tiny-invariant": "^1.2.0", "zod": "3.25.76" @@ -24,7 +23,8 @@ "devDependencies": { "@types/nodemailer": "^8.0.0", "@types/react": "18.2.69", - "@types/react-dom": "18.2.7" + "@types/react-dom": "18.2.7", + "react-email": "^6.5.0" }, "engines": { "node": ">=18.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03e96dcac..c2b2df6bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1170,9 +1170,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.3.1) - react-email: - specifier: ^6.5.0 - version: 6.5.0(bufferutil@4.0.9)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) resend: specifier: ^3.2.0 version: 3.2.0 @@ -1192,6 +1189,9 @@ importers: '@types/react-dom': specifier: 18.2.7 version: 18.2.7 + react-email: + specifier: ^6.5.0 + version: 6.5.0(bufferutil@4.0.9)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) internal-packages/llm-model-catalog: dependencies: