Files
sencho/Dockerfile
T
Anso 06d9ef5904 fix(docker): build compose plugin from ./cmd, not ./cmd/compose (#803)
* fix(docker): build compose plugin from ./cmd, not ./cmd/compose

The compose-builder stage in Dockerfile was building ./cmd/compose, which
in docker/compose v5.1.2 is `package compose` (cobra command definitions),
not the CLI plugin entry point. `go build -o file pkg` against a non-main
package writes a Go archive; COPY and chmod accept it, but the kernel
cannot exec an ar-format file, so the Docker CLI plugin manager rejected
the binary and surfaced only as `docker: unknown command: docker compose`
in the v0.64.0 release smoke gate. The plugin's actual main package is at
./cmd (cmd/main.go calls plugin.Run from the cli-plugins framework), per
the upstream Makefile in v5.1.2.

Also fix the version ldflag to target the v5 module path: in v5 the module
moved from github.com/docker/compose/v2 to .../v5, so the previous
-X .../v2/internal.Version= silently no-op'd and `docker compose version`
would have reported an empty string even after the build target fix.

Add -trimpath and -s -w to match the upstream Makefile's release flags.

Add an inline ELF-magic sanity check inside the compose-builder stage so
this exact failure mode (non-main package emitting an archive) fails the
build at the producer instead of surfacing 200 lines later. Hex match via
`od -tx1` works on both busybox and GNU coreutils; the named-character
form (`od -c`) renders fields differently across the two.

Add a binary smoke step to the docker-validate PR job that mirrors the
release-time gate, so a regression in either the CLI or compose source
build is caught at PR review time rather than at tag-push time.

* fix(vex): match Trivy purl format for bundled docker/docker version

The compose plugin build target fix exposes a real consequence: now that
compose-builder produces a true ELF binary (rather than a Go archive),
Trivy can read the embedded Go module metadata and correctly reports the
bundled docker/docker dependency as `v28.5.2+incompatible`. The existing
VEX subcomponent ID omitted the `+incompatible` Go-module suffix, so
Trivy's purl matcher rejected the suppression and CVE-2026-34040 surfaced
in the docker-validate scan against the rebuilt image.

Update the @id to the literal version Trivy emits and bump the document
version per OpenVEX spec. The justification (compose acts as a CLI
client, never as a daemon, so the authz-hook path is unreachable) is
unchanged and continues to apply to v28.5.2+incompatible identically.

* docs(security): explain why CVE-2026-34040 cannot be upgraded out

A direct in-build upgrade attempt (`go get github.com/docker/docker@<sha
of docker-v29.3.1>`) fails with:

  invalid version: go.mod has post-v1 module path
  "github.com/moby/moby/v2" at revision f78c987a

moby/moby migrated its Go module path to `github.com/moby/moby/v2` on
the docker-29.x branch. The legacy `github.com/docker/docker` import path
is therefore frozen at v28.5.2+incompatible from Go's perspective; the
proxy.golang.org listing for that module path confirms no v29.x version
is resolvable. compose v5.1.2 (and v5.1.3, the latest tag at the time of
writing) both still import the legacy path, so the bundled docker/docker
library cannot be moved past v28.5.2+incompatible until upstream compose
migrates its imports.

Document the constraint in two places so a future maintainer (or future
me) does not re-attempt the same `go get` and arrive at the same dead
end:

- Dockerfile compose-builder stage: comment block above the build step.
- security/vex/sencho.openvex.json: expand the CVE-2026-34040 statement's
  impact_statement to spell out the upstream module-split blocker. The
  not_affected status (compose runs as a CLI client, never executes the
  daemon authz hook code path) is the principled triage and remains the
  correct OpenVEX call, not a deferred upgrade.

No build or runtime behaviour changes. Pure documentation enrichment.

* fix(security): mirror CVE-2026-34040 VEX statement in .trivyignore

Trivy reports `Some vulnerabilities have been ignored/suppressed` but
still fails the docker-validate scan on CVE-2026-34040, because the
OpenVEX statement's product identifier `pkg:oci/sencho` cannot match
the build-local image tag `sencho:pr-test`. Trivy does not generate an
OCI purl for images that lack a registry digest (aquasecurity/trivy
issue 9399), so VEX product matching is a no-op at PR and release scan
time. The dual-layer pattern documented in .trivyignore's header was
designed for exactly this case: VEX is the canonical, attested,
published triage record, and .trivyignore mirrors entries that scan-time
matching cannot resolve, with a comment pointing to the corresponding
VEX justification.

The CVE-2026-34040 OpenVEX statement is unchanged (status=not_affected,
justification=vulnerable_code_not_in_execute_path). The .trivyignore
entry is a build-time scan filter only, with a referencing comment so
auditors land on the VEX file as the source of truth. CVE-2026-39883
already follows this pattern and continues to.
2026-04-27 09:10:21 -04:00

279 lines
13 KiB
Docker

# Cross-compilation helper - provides xx-clang, xx-apk, etc.
# Runs on the BUILD platform; its binaries are copied into build stages below.
# Digest pinned to prevent silent base-image changes between scan and publish.
FROM --platform=$BUILDPLATFORM tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
# Stage 1: Build Frontend
# Runs on the BUILD platform (amd64) - frontend has no native modules so the
# compiled output (JS/CSS/HTML) is entirely platform-agnostic.
FROM --platform=$BUILDPLATFORM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json frontend/.npmrc ./
RUN npm config set fetch-retry-maxtimeout 120000 && \
npm config set fetch-retries 5 && \
npm install
COPY frontend/ ./
# vite.config.ts reads the root package.json for the app version
COPY package.json /app/package.json
RUN npm run build
# Stage 2: Compile TypeScript
# Runs on the BUILD platform (amd64) - tsc output is platform-agnostic JS.
FROM --platform=$BUILDPLATFORM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS backend-builder
WORKDIR /app/backend
RUN apk add --no-cache python3 make g++
COPY backend/package*.json backend/.npmrc ./
RUN npm config set fetch-retry-maxtimeout 120000 && \
npm config set fetch-retries 5 && \
npm install
COPY backend/ ./
# prebuild hook (generate-version.js) reads the root package.json for the app version
COPY package.json /app/package.json
RUN npm run build
# Stage 3: Production dependencies (cross-compiled - NO QEMU execution)
# Runs on the BUILD platform (amd64) but compiles native modules
# (bcrypt, better-sqlite3, node-pty) for the TARGET platform using
# tonistiigi/xx + clang as the cross-compiler.
# This avoids the Node.js v20 SIGILL crash that occurs when npm runs
# under QEMU because QEMU lacks ARMv8.1 LSE atomic instruction support.
FROM --platform=$BUILDPLATFORM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS prod-deps
# Copy xx cross-compilation tools into this stage
COPY --from=xx / /
ARG TARGETARCH
ARG BUILDARCH
WORKDIR /app
# Two paths depending on whether we are cross-compiling:
#
# Native (TARGETARCH == BUILDARCH, e.g. amd64 → amd64):
# Standard g++ is used. xx-clang introduces sysroot flags that conflict with
# node-gyp's header resolution on Alpine for same-platform builds, so we
# bypass it entirely and let npm ci use the host compiler directly.
#
# Cross (TARGETARCH != BUILDARCH, e.g. amd64 → arm64):
# xx-clang targets the foreign architecture without QEMU. The target sysroot
# is populated via xx-apk:
# g++ - libstdc++ headers/libs (all three native modules use C++)
# musl-dev - musl libc headers for the target arch
# linux-headers - <pty.h> / <termios.h> required by node-pty
RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
apk add --no-cache python3 make g++; \
else \
apk add --no-cache clang lld python3 make g++ && \
xx-apk add --no-cache g++ musl-dev linux-headers; \
fi
COPY backend/package*.json backend/.npmrc ./
# Native: plain npm ci - g++ compiles native modules for the host arch.
# Cross: npm_config_arch tells prebuild-install/node-pre-gyp which pre-built
# binary to attempt; CC/CXX/AR route compilation through xx-clang so
# the output targets the foreign arch without any QEMU emulation.
RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
npm ci --omit=dev; \
else \
npm_config_arch=$TARGETARCH \
CC=xx-clang \
CXX=xx-clang++ \
AR=xx-ar \
npm ci --omit=dev; \
fi
# Stage 4a: Build Docker CLI from source against Go 1.26.2
#
# Resolves the following CVEs that were present in the upstream static Docker CLI
# binary (which ships with Go 1.26.1):
# CVE-2026-32280/32281/32282/32283: Go stdlib x509/TLS issues (fixed in Go 1.26.2)
# CVE-2026-33810: Go stdlib DNS name constraint bypass (fixed in Go 1.26.2)
# CVE-2026-33186: grpc 1.78.0 HTTP/2 server attack (CLI is client-only;
# removed from SBOM by building with go mod's updated resolution)
#
# Runs on the BUILD platform; GOARCH cross-compiles the static binary for TARGET.
# The --depth 1 clone fetches only the v29.4.0 tag commit, minimising transfer size.
# docker/cli v29.4.0 uses CalVer and ships vendor.mod instead of go.mod to avoid
# SemVer compliance requirements. We copy vendor.mod -> go.mod and build with
# -mod=vendor so all deps come from the vendored tree (no network access needed).
FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS cli-builder
ARG TARGETARCH
RUN apk add --no-cache git
RUN git clone --depth 1 --branch v29.4.0 https://github.com/docker/cli.git /src/docker-cli
WORKDIR /src/docker-cli
RUN mkdir -p /build
RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-mod=vendor \
-ldflags "-extldflags=-static \
-X github.com/docker/cli/cli/version.Version=29.4.0 \
-X github.com/docker/cli/cli/version.GitCommit=source-go1.26.2" \
-o /build/docker \
./cmd/docker
# Stage 4b: Build Docker Compose from source against Go 1.26.2
#
# Rebuilding compose with the same patched Go toolchain eliminates any Go stdlib
# CVEs that would otherwise appear in the compose binary's SBOM. Vendored
# third-party dependencies inside compose (buildkit, moby/docker, otel) are
# not reachable via Sencho's compose invocations (up/down/ps only); those
# residual CVEs are documented in security/vex/sencho.openvex.json.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS compose-builder
ARG TARGETARCH
RUN apk add --no-cache git
RUN git clone --depth 1 --branch v5.1.2 https://github.com/docker/compose.git /src/docker-compose
WORKDIR /src/docker-compose
RUN mkdir -p /build
# Build target is ./cmd (the package main with plugin.Run), per docker/compose
# v5.1.2's Makefile. The directory ./cmd/compose is package compose (cobra
# command definitions only, not main). The module path moved from /v2 to /v5
# in the v5 release, so the Version ldflag must reference /v5/internal.
#
# The bundled github.com/docker/docker dependency stays at v28.5.2+incompatible
# (compose v5.1.2's go.mod). It cannot be upgraded to docker-v29.3.1 (the
# release that contains the CVE-2026-34040 authz fix) because moby/moby's
# docker-29.x branch declares its module path as github.com/moby/moby/v2,
# making it unreachable through the legacy github.com/docker/docker import
# that compose v5.1.2 still uses. The Go module proxy reflects this and lists
# v28.5.2+incompatible as the highest resolvable version. The CVE only affects
# Docker Engine's daemon authz hook code path; compose runs as a CLI client
# and never executes that path. See security/vex/sencho.openvex.json for the
# full not_affected justification, which Trivy honours via trivy.yaml.
RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-trimpath \
-ldflags "-s -w -extldflags=-static \
-X github.com/docker/compose/v5/internal.Version=v5.1.2" \
-o /build/docker-compose \
./cmd
# Sanity check: fail the stage immediately if go build did not produce an ELF
# executable. Catches the failure mode where -o file points to a non-main
# package and Go writes an ar-format archive that passes COPY + chmod but is
# not exec-able by the kernel, surfacing only as an opaque plugin-not-found
# error from the Docker CLI plugin manager hundreds of build steps later.
# `od -tx1` is used instead of `-c` because busybox and GNU coreutils render
# `-c` with different field padding; hex output is stable across both.
RUN test -f /build/docker-compose \
&& magic=$(dd if=/build/docker-compose bs=1 count=4 status=none | od -An -tx1 | tr -d ' \n') \
&& [ "$magic" = "7f454c46" ]
# Stage 5: Production runtime
# Runs on the TARGET platform - no compilation happens here.
#
# Vulnerability scanning uses the external `trivy` CLI. It is not installed
# in this image; operators who want the feature install Trivy on the host
# and mount the binary into the container, or run a sidecar. See
# docs/operations/trivy-setup.mdx for the supported integration paths.
FROM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4
# Daily cache-bust for the apk upgrade layer. CI passes the current date
# (YYYY-MM-DD) as a build-arg, so this RUN layer's hash changes at most
# once per calendar day. Without this, buildx reuses the cached layer
# indefinitely and a new Alpine package fix (e.g. an openssl CVE patched
# upstream in alpine 3.23) sits behind the stale cache until an unrelated
# change invalidates this line by coincidence. Default value lets local
# developers build without the arg; production CI always sets it.
ARG APK_CACHE_BUST=unset
# Upgrade all Alpine system packages and install runtime deps.
# Docker CLI and Compose are copied from source-built stages below,
# eliminating the curl dependency and all Go stdlib CVEs from the upstream
# static binaries. npm is removed because it is not needed at runtime;
# removing it also eliminates CVE-2026-33671 (picomatch ReDoS in npm).
RUN echo "apk cache bust: ${APK_CACHE_BUST}" && \
apk upgrade --no-cache && \
apk add --no-cache bash su-exec && \
mkdir -p /usr/local/lib/docker/cli-plugins
# Copy the source-built Docker CLI and Compose plugin from their builder stages.
# These binaries were compiled with Go 1.26.2, resolving all Go stdlib CVEs that
# were present in the upstream static release binaries.
COPY --from=cli-builder /build/docker /usr/local/bin/docker
COPY --from=compose-builder /build/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose
RUN chmod +x /usr/local/bin/docker /usr/local/lib/docker/cli-plugins/docker-compose
# Remove npm and npx from the runtime image. npm is only needed at build time;
# shipping it in the runtime image adds unnecessary attack surface and
# introduces CVE-2026-33671 (picomatch ReDoS via the bundled npm CLI).
RUN rm -rf /usr/local/lib/node_modules/npm \
/usr/local/bin/npm \
/usr/local/bin/npx
WORKDIR /app
# Copy cross-compiled production node_modules from the prod-deps stage
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=prod-deps /app/package.json ./
# Copy compiled TypeScript output (platform-agnostic JS)
COPY --from=backend-builder /app/backend/dist ./dist
# Copy built frontend
COPY --from=frontend-builder /app/frontend/dist ./public
# Set environment to production
ENV NODE_ENV=production
# Pre-create the sencho user and group so the SENCHO_USER=sencho opt-out path
# in docker-entrypoint.sh works out of the box. The default runtime is root;
# this user only becomes relevant when an operator explicitly sets
# SENCHO_USER at runtime to drop privileges.
RUN addgroup -S sencho && adduser -S -G sencho sencho \
&& mkdir -p /app/data \
&& chown -R sencho:sencho /app
# Sencho runs as root by default. Docker management tools like Portainer,
# Dockge, Komodo, and Yacht all ship this way because mounting
# /var/run/docker.sock is already equivalent to root-on-host; a non-root
# container user buys essentially no extra isolation while breaking
# filesystem operations against bind mounts that user stacks have chowned.
#
# Operators who need a non-root container (compliance scanners, rootless
# Docker with UID mapping, organisational policy) can set SENCHO_USER=sencho
# at runtime. The entrypoint handles the privilege drop, data-volume
# ownership, and Docker socket GID matching in that path.
#
# USER directive intentionally absent so the entrypoint controls the runtime
# user. Static security scanners (Trivy, Docker Scout) may flag this as
# "running as root" which is the documented and intended default.
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Strip Windows CRLF line endings that can sneak in on Windows dev machines
# even with .gitattributes eol=lf, then make executable. A shell script with
# \r in tokens like "fi\r" will fail with "unexpected end of file" in Alpine.
RUN sed -i 's/\r//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 1852
# Health check - polls the public /api/health endpoint every 30s
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "const h=require('http');h.get('http://localhost:1852/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
# Entrypoint ensures /app/data is writable and execs the CMD as root by default,
# or drops to $SENCHO_USER via su-exec when that env var is set (see comment above).
# CMD provides the default arguments passed through to the entrypoint.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]