Files
sencho/Dockerfile
T
Anso 392bc15d91 feat(git): swap isomorphic-git for native git transport behind clone seam (#1849)
* feat(git): swap isomorphic-git for native git transport behind clone seam

Replace the isomorphic-git engine (HTTP-only, single importer) with the
native git CLI behind the existing withClonedRepo seam, so SSH deploy
keys, ref semantics, and private CAs become reachable in later PRs.

- resolve-before-fetch: ls-remote pins the branch to an immutable SHA,
  then rev-parse verifies the checkout against it; tip races refuse
- hardened spawns: argv arrays only, protocol allowlist (https only),
  neutralized hooks, isolated HOME and all config channels, no prompts
- token reaches git only via a credential helper reading SENCHO_GIT_TOKEN
  from the child env; never argv or URL
- size cap becomes a workspace watchdog (on-disk measure) keeping the
  same knob and breach message; deterministic final gate added
- Windows: pin http.sslBackend=openssl (schannel ignores sslCAInfo) and
  anchor to Git's bundled CA; NODE_EXTRA_CA_CERTS combines with platform
  defaults instead of replacing them
- error classification retargets to exit code + stderr while preserving
  the contractual mappings (AUTH_FAILED maps to 400, never 401;
  unauthenticated refusals mask as REPO_NOT_FOUND)
- runtime image installs git; tests re-pointed at the transport boundary
  plus a new engine suite (classifier corpus, argv hardening, watchdog)

Zero externally visible behavior change except two edge cases: an empty
branch now surfaces BRANCH_NOT_FOUND, and a mid-fetch force push refuses
instead of materializing the moved tip.

* fix(git): unblock CI on linux kill-path test and codeql log warning

Two CI-only findings from the first pipeline run:

- The scripted spawn child in the transport tests lacked the kill method
  that killTree's POSIX fallback reaches when a fake process group does
  not exist; Linux runs crashed inside the timeout tests while Windows
  (taskkill branch) could not reproduce it. Give the fixture the method
  the real ChildProcess always has.
- CodeQL flagged the workspace-removal warning that interpolated the
  NODE_EXTRA_CA_CERTS path (environment-sourced values are treated as
  sensitive at log sinks). Reword the warning to name the variable
  instead of its value; operators know their own environment.

* fix(git): collapse remaining duplicated test setup so the shared helper is used

* fix(git): close watchdog, size-gate, ref-validator, and kill-ordering gaps in native transport

Resolves the release-blocking findings from an independent pre-merge audit
of the native git transport swap:

- A watchdog-triggered kill mid-clone was misclassified as a generic exit
  failure instead of a size breach, because runGit resolves (not rejects)
  when the child is killed via SIGKILL.
- The final on-disk size measurement failed open when it could not be
  read (workspace removed mid-walk, permissions), letting an unmeasured
  clone through as a success. Now fails closed and logs the real cause.
- The ref-name validator was an overly restrictive allow-list that
  rejected valid branch names (leading underscore, non-ASCII, '#').
  Replaced with a deny-list matching real `git check-ref-format --branch`
  semantics, verified against the git binary, including a per-path-segment
  `.lock` check the first pass missed.
- runGit's timeout handler settled as soon as a kill was issued rather
  than confirmed, racing workspace cleanup against a still-alive child
  tree. It now waits for the child's close event, with a bounded fallback
  if termination is never confirmed, and preserves the timeout
  classification if 'error' fires after the kill.
- Windows killTree now also falls back to child.kill() when taskkill
  itself exits non-zero, not just when it fails to spawn.
- Added a real, non-mocked integration test that drives the credential
  helper through the actual git binary against a local HTTPS server with
  Basic Auth checking. It caught a genuine bug the mocked suite could not
  see: the credential.helper config value was quoted in a way that broke
  git's own absolute-path helper detection, failing every authenticated
  clone. Fixed by removing the quotes.
- Migrated a separately developed test file's mocks off the deleted
  isomorphic-git module onto the native transport seam, matching the
  pattern already used elsewhere, after merging with main pulled in that
  feature.

Also updates two stale comments left over from the isomorphic-git era and
adds a git version check to the Docker runtime image smoke tests.

* fix(git): make credential-helper path safe, unify ref length, and fix Windows kill ordering

Addresses three PR 1 correction items from pre-merge audit:

- credential.helper is a shell string, not argv: interpolating the
  helper's workspace-relative path broke authenticated fetches whenever
  the workspace sat under a directory with a space in its name. The
  config value is now a fixed string that names an environment
  variable instead, so no workspace path character can affect how
  git's shell parses it.
- The transport rejected branch names over 200 characters while the
  route accepted up to 256 and real git has no comparable limit.
  REF_MAX_LEN is now a single exported constant shared by the
  transport and both routes.
- On Windows, taskkill runs as a separate process and could still be
  walking a killed process tree after the direct git child reported
  closed, letting the caller delete the workspace early. Kill
  operations are now awaited to completion (bounded by a timeout)
  before a timed-out or size-breached run settles, on both the close
  and error event paths.

Verified against a real authenticated git server inside the built
runtime image: public HTTPS, private HTTPS with a valid PAT, invalid
PAT, a deleted branch, an oversized repository, and the awkward
workspace-path case, including from a workspace path containing
spaces and shell metacharacters.

* fix(git): reap killed helpers and classify curl refusals
2026-08-28 02:00:08 +00:00

358 lines
18 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:26-alpine@sha256:aadf416b2cdce311a8811ba3f0608a61b77dbf997500e2eafe781b51f6a0b019 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 ci
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:26-alpine@sha256:aadf416b2cdce311a8811ba3f0608a61b77dbf997500e2eafe781b51f6a0b019 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 ci
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:26-alpine@sha256:aadf416b2cdce311a8811ba3f0608a61b77dbf997500e2eafe781b51f6a0b019 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.3
#
# CLI v29.4.1 ships otel/sdk v1.43.0, resolving CVE-2026-39883 (BSD kenv) and
# CVE-2026-39882 (OTLP response OOM). It also carries the CVE-2025-15558 fix
# (Windows plugin search path LPE, fixed since v29.2.0). Building from source
# with Go 1.26.3 additionally eliminates Go stdlib CVEs present in the upstream
# static binary.
#
# Runs on the BUILD platform; GOARCH cross-compiles the static binary for TARGET.
# The fetch pulls only the v29.4.1 commit, minimising transfer size.
# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid SemVer
# compliance requirements. We copy vendor.mod -> go.mod, drop the committed vendor
# tree, bump golang.org/x/net to v0.56.0, golang.org/x/text to v0.39.0,
# google.golang.org/grpc to v1.82.1, and github.com/moby/go-archive to v0.3.0,
# and build with -mod=mod so the patched modules are resolved from the module
# proxy. x/net v0.53.0 is flagged for six
# HIGH advisories (CVE-2026-25680, -25681, -27136, -39821, -42502, -42506;
# x/net/html parsing and x/net/idna). x/net v0.55.0 is flagged for
# CVE-2026-46600 (dnsmessage denial of service). x/text v0.37.0 is flagged for
# CVE-2026-56852 (norm.Iter infinite loop on crafted input). grpc v1.80.0 is
# flagged for GHSA-hrxh-6v49-42gf (xDS RBAC / HTTP/2). go-archive v0.2.0 is
# flagged for CVE-2026-17106 (HIGH), where a crafted tar archive can write
# outside the extraction directory. Removing vendor/ keeps
# -mod=mod from reading the stale copy, and avoids `go mod tidy` (which does
# not run cleanly against docker/cli's vendor.mod manifest). This stage now
# fetches modules at build time rather than building fully offline.
# Base image pinned by digest so the Go toolchain that compiles the static
# Docker CLI binary cannot change without an explicit Dependabot bump.
FROM --platform=$BUILDPLATFORM golang:1.27rc3-alpine@sha256:c5aca77a4d16cb6688dbf3ccade67eff6f05ee208bc854d060e6947f5c27e23c AS cli-builder
ARG TARGETARCH
RUN apk add --no-cache git
# Fetch by commit SHA rather than by mutable tag. The SHA below resolves to
# the docker/cli v29.4.1 release; recorded here for traceability since the
# raw SHA does not carry semantic information.
RUN git init /src/docker-cli && \
cd /src/docker-cli && \
git remote add origin https://github.com/docker/cli.git && \
git fetch --depth=1 origin 407f3428e5c5a3a4088f9268bc7159f5e0f95bea && \
git checkout FETCH_HEAD
WORKDIR /src/docker-cli
RUN mkdir -p /build
RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
rm -rf vendor && \
go get golang.org/x/net@v0.56.0 \
golang.org/x/text@v0.39.0 \
google.golang.org/grpc@v1.82.1 \
github.com/moby/go-archive@v0.3.0 && \
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-mod=mod \
-ldflags "-extldflags=-static \
-X github.com/docker/cli/cli/version.Version=29.4.1 \
-X github.com/docker/cli/cli/version.GitCommit=source-go1.26.3" \
-o /build/docker \
./cmd/docker
# Stage 4b: Build Docker Compose from source against Go 1.26.3
#
# Compose v5.1.3 moved github.com/docker/docker from a direct require to an
# indirect dep (the direct surface is now moby/moby/api + moby/moby/client),
# but the docker/docker module is still pulled into the binary via buildkit
# and other transitive paths. Several daemon-side CVEs against docker/docker
# v28.5.2 (CVE-2026-34040, CVE-2026-33997, CVE-2026-41567, CVE-2026-41568,
# CVE-2026-42306) therefore still appear in scans of the compose binary;
# they are tracked as not_affected in security/vex/sencho.openvex.json
# because compose is the client and the vulnerable code paths are reached
# only by Docker Engine the daemon. Rebuilding with the patched Go
# toolchain eliminates Go stdlib CVEs from the binary's SBOM.
#
# Compose v5.1.3 still bundles otel/sdk v1.42.0 transitively via buildkit
# v0.29.0. The go get step below bumps otel to v1.43.0 to resolve
# CVE-2026-39883 (BSD kenv) and CVE-2026-39882 (OTLP response OOM) so that
# the compose binary scans completely clean. It also bumps
# github.com/moby/go-archive to v0.3.0 for CVE-2026-17106 (HIGH), where a
# crafted tar archive can write outside the extraction directory; compose
# pulls the same archive code in transitively through buildkit.
#
# Compose v5.1.3 also pins github.com/containerd/containerd/v2 v2.2.3, which
# carries CVE-2026-46680 (runAsNonRoot evasion in containerd's runtime
# executor). v2.2.4 cleared that one, but a later containerd advisory cluster
# (CVE-2026-53488, CVE-2026-53489, CVE-2026-53492, all CRI checkpoint/restore
# and restart-monitor issues) affects v2.2.4 and is fixed in v2.2.5. The go get
# step below bumps containerd/v2 to v2.2.5 to clear all four. These are
# patch-level fixes with no breaking API changes. Every vulnerable code path is
# daemon-side (containerd's CRI service) and is not reached by compose at all,
# so this is defense-in-depth rather than a live exposure.
#
# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.82.1 to
# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues),
# golang.org/x/text from v0.38.0 to v0.39.0 to clear CVE-2026-56852
# (norm.Iter infinite loop on crafted input), and golang.org/x/net from
# v0.55.0 to v0.56.0 to clear CVE-2026-46600 (dnsmessage denial of service).
# Base image pinned by digest (same image as cli-builder above) so both
# source builds share an identical, immutable Go toolchain.
FROM --platform=$BUILDPLATFORM golang:1.27rc3-alpine@sha256:c5aca77a4d16cb6688dbf3ccade67eff6f05ee208bc854d060e6947f5c27e23c AS compose-builder
ARG TARGETARCH
RUN apk add --no-cache git
# Fetch by commit SHA. The SHA below resolves to the docker/compose v5.1.3
# release; recorded here for traceability since the raw SHA does not carry
# semantic information.
RUN git init /src/docker-compose && \
cd /src/docker-compose && \
git remote add origin https://github.com/docker/compose.git && \
git fetch --depth=1 origin 5b2badbda44f3410b2a6c58dff79def21fe8b13e && \
git checkout FETCH_HEAD
WORKDIR /src/docker-compose
RUN mkdir -p /build
# Patch otel/sdk and exporters from v1.42.0 → v1.43.0 to clear CVE-2026-39883
# and CVE-2026-39882, bump containerd/v2 from v2.2.3 → v2.2.5 to clear
# CVE-2026-46680 plus the CVE-2026-53488 / 53489 / 53492 cluster, bump
# google.golang.org/grpc to v1.82.1 to clear GHSA-hrxh-6v49-42gf, and bump
# golang.org/x/net to v0.56.0 to clear CVE-2026-46600. The containerd bump is
# patch-level; the otel, grpc, and x/net bumps are minor security releases.
# None introduce breaking API changes.
RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \
go get go.opentelemetry.io/otel@v1.43.0 \
go.opentelemetry.io/otel/sdk@v1.43.0 \
go.opentelemetry.io/otel/sdk/metric@v1.43.0 \
go.opentelemetry.io/otel/metric@v1.43.0 \
go.opentelemetry.io/otel/trace@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.43.0 \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0 \
github.com/containerd/containerd/v2@v2.2.5 \
google.golang.org/grpc@v1.82.1 \
golang.org/x/text@v0.39.0 \
golang.org/x/net@v0.56.0 \
github.com/moby/go-archive@v0.3.0 && \
go mod tidy
# Build target is ./cmd (the package main with plugin.Run), per docker/compose'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.
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.3" \
-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:26-alpine@sha256:aadf416b2cdce311a8811ba3f0608a61b77dbf997500e2eafe781b51f6a0b019
# 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.
# git is required at runtime: Git Sources clones through the native git
# client. 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 git tini && \
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.3, 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))"
# Tini owns PID 1 so orphaned Git transport helpers are reaped after a
# process-group kill. The entrypoint still prepares /app/data before execing
# the application command.
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]