Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,59 @@ jobs:
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
if-no-files-found: error

build-chainguard:
name: Build Chainguard Docker images
runs-on: ubuntu-22.04
needs: lint
permissions:
contents: read
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- project_name: Admin
base_path: ./src
- project_name: Api
base_path: ./src
- project_name: Attachments
base_path: ./util
- project_name: Billing
base_path: ./src
- project_name: Events
base_path: ./src
- project_name: EventsProcessor
base_path: ./src
- project_name: Icons
base_path: ./src
- project_name: Identity
base_path: ./src
- project_name: MsSqlMigratorUtility
base_path: ./util
- project_name: Notifications
base_path: ./src
- project_name: Scim
base_path: ./bitwarden_license/src
- project_name: Sso
base_path: ./bitwarden_license/src
steps:
- name: Check out repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

- name: Build Chainguard Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile.gov
platforms: linux/amd64
push: false

bitwarden-lite-build:
name: Trigger Bitwarden lite build
if: github.event_name != 'pull_request'
Expand Down
56 changes: 56 additions & 0 deletions bitwarden_license/src/Scim/Dockerfile.gov
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
###############################################
# Build stage #
###############################################
FROM --platform=$BUILDPLATFORM cgr.dev/bitwarden.com/dotnet-sdk-fips:10-dev AS build

USER root

# Docker buildx supplies the value for this arg
ARG TARGETPLATFORM

RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
RID=linux-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-arm64 ; \
else echo "Unsupported TARGETPLATFORM: $TARGETPLATFORM" >&2 && exit 1 ; \
fi \
&& echo "RID=$RID" > /tmp/rid.txt

# Copy required project files
WORKDIR /source
COPY . ./

# Restore project dependencies and tools
WORKDIR /source/bitwarden_license/src/Scim
RUN . /tmp/rid.txt && dotnet restore -r $RID

# Build project
RUN . /tmp/rid.txt && dotnet publish \
-c release \
--no-restore \
--self-contained \
/p:PublishSingleFile=true \
-r $RID \
-o out

###############################################
# App stage #
###############################################
FROM cgr.dev/bitwarden.com/dotnet-runtime-fips:10

LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV globalSettings__logDirectory=
EXPOSE 5000

WORKDIR /app
COPY --from=build --chown=65532:65532 /source/bitwarden_license/src/Scim/out /app

# Run as the built-in nonroot user
USER 65532

# Run app binary as PID 1
ENTRYPOINT ["/app/Scim"]
56 changes: 56 additions & 0 deletions bitwarden_license/src/Sso/Dockerfile.gov
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
###############################################
# Build stage #
###############################################
FROM --platform=$BUILDPLATFORM cgr.dev/bitwarden.com/dotnet-sdk-fips:10-dev AS build

USER root

# Docker buildx supplies the value for this arg
ARG TARGETPLATFORM

RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
RID=linux-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-arm64 ; \
else echo "Unsupported TARGETPLATFORM: $TARGETPLATFORM" >&2 && exit 1 ; \
fi \
&& echo "RID=$RID" > /tmp/rid.txt

# Copy required project files
WORKDIR /source
COPY . ./

# Restore project dependencies and tools
WORKDIR /source/bitwarden_license/src/Sso
RUN . /tmp/rid.txt && dotnet restore -r $RID

# Build project
RUN . /tmp/rid.txt && dotnet publish \
-c release \
--no-restore \
--self-contained \
/p:PublishSingleFile=true \
-r $RID \
-o out

###############################################
# App stage #
###############################################
FROM cgr.dev/bitwarden.com/dotnet-runtime-fips:10

LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV globalSettings__logDirectory=
EXPOSE 5000

WORKDIR /app
COPY --from=build --chown=65532:65532 /source/bitwarden_license/src/Sso/out /app

# Run as the built-in nonroot user
USER 65532

# Run app binary as PID 1
ENTRYPOINT ["/app/Sso"]
209 changes: 209 additions & 0 deletions chainguard-variants-assessment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# Chainguard Dockerfile Variants β€” Assessment

## Summary

This repo carries **11 `chainguard.Dockerfile` variants** that mirror the standard service `Dockerfile`s but rebase them from Microsoft's Alpine .NET images onto Chainguard (Wolfi) images. The variants are intentionally minimal "lift-and-shift" rewrites: same multi-stage shape, same build/publish commands, same entrypoint β€” only the base images and the platform/packaging details that *must* change for Wolfi
were touched.

The single most important property of these variants is that they build against **floating `latest-dev` tags**, not pinned versions. The standard Dockerfiles are version-pinned (`10.0-alpine3.23`); the Chainguard variants cannot be, because the public Chainguard tier only publishes mutable `latest` / `latest-dev` (see [Why we must test "as-is"](#why-we-must-test-as-is-unpinned-images)). That makes a **continuous CI build the only way to know whether these images still build** β€” the target moves underneath us.

## Scope β€” the 11 variants

| Service | Path | Notes |
|---|---|---|
| Admin | `src/Admin/chainguard.Dockerfile` | Has an extra Node.js build stage |
| Api | `src/Api/chainguard.Dockerfile` | |
| Billing | `src/Billing/chainguard.Dockerfile` | |
| Events | `src/Events/chainguard.Dockerfile` | |
| EventsProcessor | `src/EventsProcessor/chainguard.Dockerfile` | |
| Icons | `src/Icons/chainguard.Dockerfile` | |
| Identity | `src/Identity/chainguard.Dockerfile` | |
| Notifications | `src/Notifications/chainguard.Dockerfile` | sysctl tweak (see below) |
| Attachments | `util/Attachments/chainguard.Dockerfile` | Builds `util/Server` |
| Scim | `bitwarden_license/src/Scim/chainguard.Dockerfile` | |
| Sso | `bitwarden_license/src/Sso/chainguard.Dockerfile` | |

> A 12th variant, `src/Api/chainguard-distroless.Dockerfile`, was removed earlier
> and is intentionally out of scope.

## Differences from the standard Dockerfiles

Every variant applies the **same six changes**. The lower half of each file (app-stage
`COPY --from=build`, `entrypoint.sh` copy, `HEALTHCHECK`, `ENTRYPOINT`) is unchanged.

| # | Change | Standard | Chainguard | Why |
|---|---|---|---|---|
| 1 | **Build base** | `mcr.microsoft.com/dotnet/sdk:10.0-alpine3.23` | `cgr.dev/chainguard/dotnet-sdk:latest-dev` | Rebase to Chainguard |
| 2 | **Runtime base** | `mcr.microsoft.com/dotnet/aspnet:10.0-alpine3.23` | `cgr.dev/chainguard/aspnet-runtime:latest-dev` | Rebase to Chainguard |
| 3 | **`USER root`** | (implicit root) | Added after **both** `FROM`s | Chainguard images default to a **nonroot** user; the build steps and the root-based entrypoint need root |
| 4 | **Runtime ID** | `linux-musl-x64` / `-arm64` / `-arm` | `linux-x64` / `-arm64` / `-arm` | Wolfi is **glibc**, not musl; a musl self-contained binary won't run on the glibc runtime base |
| 5 | **ICU package** | `icu-libs` | `icu` | Wolfi package naming |
| 6 | **gosu install** | `apk add … --repository=…alpine/edge/community gosu` | `gosu` (default repos) | Wolfi's apk can't use the Alpine edge repo/keys; `gosu` is in Wolfi's default repos |

### `-dev` (not minimal) base images

Both Chainguard bases use the **`-dev`** flavor (`dotnet-sdk:latest-dev`,
`aspnet-runtime:latest-dev`, and `node:latest-dev` for Admin). This is deliberate:

- The **build stage** runs shell-form `RUN` steps (RID detection, sourcing
`/tmp/rid.txt`, `dotnet restore/publish`) and `npm ci`/`npm run build` for Admin β€”
all of which need a shell, a package manager, and root that the minimal images lack.
- The **runtime stage** keeps the existing `entrypoint.sh`, which runs as root, uses
`/bin/sh` and the `shadow` utilities (`groupadd`/`usermod`/`mkhomedir_helper`), then
steps down to the `bitwarden` user via `gosu`. The minimal, distroless
`aspnet-runtime:latest` (nonroot, no shell) cannot execute it.

### Service-specific deltas

- **Admin** β€” additionally rebases the Node build stage
`node:24-alpine3.21` β†’ `cgr.dev/chainguard/node:latest-dev` + `USER root`. Nothing
from this stage ships in the runtime image (only the built `wwwroot` is copied
forward), so the larger dev image has no runtime footprint.
- **Notifications** β€” the two `sysctl.d` writes were wrapped with `mkdir -p
/etc/sysctl.d` (and joined into one `RUN`). The Wolfi runtime base ships **no
`/etc/sysctl.d` directory**, so the original bare `>>` redirects failed the build
(exit 1). This was fixed during this session. *(Note: these settings are inert in a
container unless applied by the runtime β€” e.g. k8s `securityContext.sysctls` β€” but
the fix preserves prior intent without breaking the build.)*
- **Api** β€” minor cosmetic reordering of the apk package list; no functional change.

## Why we must test "as-is" (unpinned images)

This is the core justification for wiring a dedicated CI build for these variants.

### The pinning gap is structural, not a choice

The standard Dockerfiles pin an exact version: `dotnet/sdk:10.0-alpine3.23`. The
Chainguard variants **cannot** express the same pin. The public `cgr.dev/chainguard`
tier publishes **only `latest` and `latest-dev`** for `dotnet-sdk`, `aspnet-runtime`,
and `node` β€” every other tag is a cosign sidecar (`.sig`/`.att`/`.sbom`). Version-numbered
and dated tags (e.g. `dotnet-sdk:9.0`) are a **paid** Chainguard offering. On the tier
these builds can reach, the only expressible pin is a `@sha256:` digest β€” and on the
free tier those digests are eventually garbage-collected, so a digest pin is not durable.

**Consequence:** until a pinned/versioned source is available, every build of these
variants consumes whatever `latest-dev` happens to be *today*. That is exactly what a
production-equivalent build would consume too β€” so the realistic thing to validate is
the moving target itself.

### The moving target demonstrably moves β€” and breaks

Evidence gathered during this session (same commit, two CI runs a day apart):

- The `dotnet-sdk:latest-dev` tag rolled between runs:
- `2026-06-15` build β†’ digest `439228d9…` β€” **built cleanly**.
- `2026-06-16` build β†’ digest `2461a82a…` β€” **CLR-crashed** on `dotnet publish`
(`Internal CLR error (0x80131506)`, SIGSEGV / exit 139) within 0.6s.
- A separate run hit a transient `libsemanage: Permission denied` during `apk add`
in the runtime stage β€” the identical `apk` command succeeded in 6 sibling jobs in the
same run, i.e. environmental/buildkit noise, not a Dockerfile defect.
- The Notifications `sysctl.d` break was a deterministic failure that only surfaced
because the variant was actually built.

These are precisely the classes of problem a one-time local build would miss:

1. **Upstream regressions** in a floating base image (the CLR crash) β€” would silently
land in any fresh build the day the tag rolls.
2. **Transient infrastructure faults** (registry 500s, apk extraction races) that need
retry/resilience design, not code fixes.
3. **Wolfi-vs-Alpine behavioral gaps** (missing `/etc/sysctl.d`, package renames, musl
vs glibc) that only fail at build/run time.

A continuously-running build is therefore not redundant with the existing image CI β€” it
is the **only signal** that tells us whether the Chainguard variants are currently
buildable against the only image source available to them, and surfaces upstream breakage
the moment it is introduced rather than at migration time.

## Security posture note

These variants knowingly **trade away most of Chainguard's hardening** to preserve the
existing runtime contract:

- Using the `-dev` runtime re-introduces a shell, a package manager, and root.
- `USER root` in the app stage runs the container as root up to the `gosu` step-down.

The Chainguard-native end state β€” minimal (non-`-dev`) `aspnet-runtime` running as the
built-in nonroot user β€” requires reworking `entrypoint.sh` to drop the root +
`groupadd`/`gosu` pattern. That is the recommended follow-up once the variants build
reliably; it is what actually realizes the distroless/nonroot benefit that motivates the
move to Chainguard in the first place.

## FIPS migration scoping (future β€” NOT implemented)

> **Status:** These variants are **not FIPS** today. All 11 build against the standard
> Wolfi developer images (`dotnet-sdk:latest-dev`, `aspnet-runtime:latest-dev`,
> `node:latest-dev`); no Dockerfile references a FIPS image, provider, or config. This
> section scopes a *future* migration β€” nothing here is wired up yet.

### What FIPS compliance actually requires

FIPS 140-2/140-3 is a validation status of the **cryptographic module**, not a label
that can be applied to an arbitrary image. For a Chainguard + .NET-on-Linux container it
spans three layers, and the base image is only one of them:

1. **A FIPS-validated crypto module in the image.** Chainguard ships FIPS images as a
**separate, paid (FedRAMP/FIPS) catalog** β€” distinct image references shipping a
FIPS-validated OpenSSL provider plus the matching `openssl.cnf`. The public
`latest`/`latest-dev` images used here do not include it. **Prerequisite: paid
Chainguard FIPS-catalog access** (same registry-access question raised for pinned
versioned tags).
2. **The app routing crypto through that module.** .NET on Linux implements no crypto of
its own β€” it delegates to the system OpenSSL, so FIPS behavior depends on the FIPS
provider being the active one. **Critical interaction with our build:** the
self-contained, single-file publish (`--self-contained /p:PublishSingleFile=true`)
bundles the *.NET runtime* but **not** OpenSSL β€” crypto still comes from the runtime
base image's OpenSSL. So the runtime base (not the SDK build stage) is what governs
FIPS at execution time.
3. **The host kernel.** `/proc/sys/crypto/fips_enabled` reflects the **host**, not the
image. Container FIPS posture is therefore partly a property of where it runs (k8s
nodes, etc.), not solely how it is built β€” this must be owned by the platform/infra
side, not just these Dockerfiles.

### Dockerfile changes the migration would entail

- Repoint **both** `FROM`s (and Admin's node stage) from the standard images to the
FIPS-catalog equivalents. The build stage matters less than the **runtime** base, per
layer 2 above β€” confirm the *runtime* base is the FIPS variant.
- Re-evaluate the `-dev` + `USER root` + `gosu` pattern against the FIPS image's
user/shell model (it may differ from the standard `-dev` images).
- Keep `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false` in mind; ICU and crypto are
separate, but any base swap re-tests the package set (`icu`, `krb5`, `shadow`, `gosu`).

### Verification checklist (to wire into CI once on FIPS images)

**Static β€” confirm the image is the FIPS build:**
- [ ] SBOM/attestation shows the FIPS OpenSSL provider package:
`cosign download sbom <fips-ref> | grep -i 'fips\|openssl'`
- [ ] `cosign verify-attestation <fips-ref> …` (FIPS images ship attestations)
- [ ] FIPS provider module + config present in image (`fips.so`, `fipsmodule.cnf`,
`fips = fips_sect` enabled in `openssl.cnf`)

**Runtime β€” confirm crypto is in FIPS mode (inside a running container):**
- [ ] `openssl list -providers` shows the `fips` provider active
- [ ] `/etc/ssl/openssl.cnf` has the fips section enabled with `.include` of `fipsmodule.cnf`
- [ ] `cat /proc/sys/crypto/fips_enabled` β†’ `1` *(host-dependent; document the boundary)*

**Behavioral β€” confirm .NET actually binds to the module:**
- [ ] A probe in the image calls a non-approved algorithm (e.g. `MD5`) and **throws**
under the FIPS provider, while an approved one (AES, SHA-256) succeeds β€” proving
the runtime isn't silently falling back to the default provider.

### Open prerequisites before this can start

1. Paid Chainguard FIPS-catalog access (and confirmation the exact image references exist
for `dotnet-sdk`/`aspnet-runtime`/`node`).
2. A decision on the compliance **boundary** β€” image-only vs image-on-FIPS-host β€” since
layer 3 is owned by infra.
3. The nonroot/`entrypoint.sh` rework (below) is orthogonal but worth sequencing
alongside, since both touch the runtime base.

## Recommendations

1. **Keep the build-only CI job running** against `latest-dev` to continuously detect
upstream breakage in the floating images (the primary value described above).
2. **Pursue a durable pinned source** β€” either a paid Chainguard versioned tag or a
mirror of a known-good digest into GHCR/ACR β€” so builds become reproducible and
bisectable. Until then, treat red builds as "is the current upstream image good?"
signal, not necessarily a defect in this repo.
3. **Plan the nonroot rework** of `entrypoint.sh` so the variants can move to the
minimal, distroless bases and actually gain the security posture Chainguard provides.
Loading
Loading