Merge pull request #18 from TheZwiss/security/container-hardening
Container hardening: non-root + image scan (report-only)
This commit is contained in:
@@ -27,6 +27,7 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -76,6 +77,45 @@ jobs:
|
|||||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
|
||||||
|
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||||
|
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||||
|
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||||
|
# single-arch build first; the multi-arch push below reuses these layers
|
||||||
|
# from the buildx cache, so this is cheap.
|
||||||
|
- name: Build amd64 image for scanning
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: backspace:scan
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Trivy image scan (report-only)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: image
|
||||||
|
image-ref: backspace:scan
|
||||||
|
ignore-unfixed: true
|
||||||
|
format: sarif
|
||||||
|
output: trivy-image.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Upload Trivy image SARIF
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true # a scanner/SARIF-emit flake must never skip the publish below
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-image.sarif
|
||||||
|
category: trivy-image
|
||||||
|
|
||||||
|
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||||
|
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||||
- name: Build and push (linux/amd64, linux/arm64)
|
- name: Build and push (linux/amd64, linux/arm64)
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
@@ -86,6 +126,8 @@ jobs:
|
|||||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
sbom: true
|
||||||
|
provenance: true
|
||||||
# Cache multi-arch layers across runs via the GitHub Actions cache to
|
# Cache multi-arch layers across runs via the GitHub Actions cache to
|
||||||
# keep the ~1.6 GB build from re-running cold every release.
|
# keep the ~1.6 GB build from re-running cold every release.
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|||||||
+9
-2
@@ -38,9 +38,10 @@ FROM node:20-slim AS runtime
|
|||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||||
|
|
||||||
# Install build dependencies for better-sqlite3 native module
|
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
|
||||||
|
# entrypoint). No C toolchain — better-sqlite3 and sharp load prebuilt binaries.
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -72,6 +73,11 @@ COPY --from=builder /app/packages/web/dist packages/web/dist
|
|||||||
# Create data directories
|
# Create data directories
|
||||||
RUN mkdir -p /app/data/uploads
|
RUN mkdir -p /app/data/uploads
|
||||||
|
|
||||||
|
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||||
|
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Set environment defaults
|
# Set environment defaults
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
@@ -94,4 +100,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
|
|||||||
|
|
||||||
# Run the server using tsx from the server package directory
|
# Run the server using tsx from the server package directory
|
||||||
WORKDIR /app/packages/server
|
WORKDIR /app/packages/server
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||||
|
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||||
|
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||||
|
# the bind mount was created by a different uid.
|
||||||
|
#
|
||||||
|
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||||
|
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||||
|
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||||
|
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||||
|
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||||
|
# (the old root container booted fine on such mounts).
|
||||||
|
set -e
|
||||||
|
mkdir -p /app/data/uploads
|
||||||
|
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||||
|
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||||
|
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||||
|
exec gosu node "$@"
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
# Plan B — Container Hardening & Real Image Scanning Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Harden the published container image — run it as a non-root user, slim its runtime attack surface, and scan the actual amd64 image for OS/library CVEs before publishing — without breaking existing self-hosters or the multi-arch (amd64+arm64) GHCR publish.
|
||||||
|
|
||||||
|
**Architecture:** Two edits to the runtime layer (`Dockerfile` + a new `docker-entrypoint.sh`) plus a restructure of `docker-publish.yml` so a single-arch amd64 image is built and Trivy-scanned before the multi-arch push. The image scan is **report-only** here (matching Plan A's sequencing); Plan E flips it to blocking. SBOM + SLSA provenance are attached at push time.
|
||||||
|
|
||||||
|
**Tech Stack:** Docker multi-stage build (`node:20-slim`), Docker Buildx + QEMU, GitHub Actions, Aqua Trivy (image + SARIF), `gosu` for privilege drop, better-sqlite3 (prebuilt binary), tsx (runtime TS loader).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **This plan builds on Plan A's branch** (`security/scanning-pipeline`); the workflows here are already SHA-pinned. Work branch: `security/container-hardening`.
|
||||||
|
- **Do not break existing self-hosters.** The `./data:/app/data` bind mount (`docker-compose.yml:31`) is host-owned; the container must still read/write it after `docker pull` + restart. The non-root switch is handled by an entrypoint that chowns `/app/data` **as root** then drops to the `node` user via `gosu` — so there is **no static `USER` line** (a static `USER` would run the entrypoint unprivileged and make the chown impossible).
|
||||||
|
- **Keep `ffmpeg`** (real runtime dependency) and **keep `tsx`** (the `CMD` runs TS via `tsx/esm`). Only `python3 make g++` may leave the runtime stage.
|
||||||
|
- **better-sqlite3 must still load.** It is expected to install via its prebuilt binary on `node:20-slim` (glibc) for both amd64 and arm64. If a task's build shows it compiling (needs the toolchain), use the documented fallback (keep the toolchain, OR copy the built module from the builder stage) and report it — do not ship a broken image.
|
||||||
|
- **Image scan is report-only in this plan** (`exit-code: '0'` + `continue-on-error: true`, comment `# report-only; enforcement flipped on in Plan E`). Do NOT make it fail the publish here.
|
||||||
|
- **trivy-action pinned to `ed142fd0673e97e23eac54620cfb913e5ce36c25` (# v0.36.0)** — v0.28.0's nested `setup-trivy@v0.2.1` ref is broken (see memory `ci-security-action-gotchas`). SHA-pin any other new action with a `# vX.Y.Z` comment.
|
||||||
|
- **Both build paths must keep working:** the GHCR prebuilt-image pull (`docker-compose.yml` `image:`) AND the from-source `docker compose up --build` fallback.
|
||||||
|
- **Commit identity:** plain `git commit` (local config = `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>`). NEVER `-c user.email`; never the alxtrading94 email.
|
||||||
|
- **Node 20 / pnpm 10.34.3** are the pinned toolchain.
|
||||||
|
- **Docker daemon must be running** for Tasks 1 and 2 verification (`docker build` / `buildx --load`). If it is not up, STOP and report — do not mark a Dockerfile task done without a real build+boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Harden the runtime image (non-root via gosu, drop build toolchain)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docker-entrypoint.sh`
|
||||||
|
- Modify: `Dockerfile` (runtime stage, lines 37-97)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the existing builder stage output (`/app/packages/web/dist`).
|
||||||
|
- Produces: an image that runs `node --import tsx/esm src/index.ts` as the non-root `node` user (uid 1000) with a writable `/app/data`. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the entrypoint script**
|
||||||
|
|
||||||
|
Create `docker-entrypoint.sh` at the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||||
|
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||||
|
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||||
|
# the bind mount was created by a different uid.
|
||||||
|
#
|
||||||
|
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||||
|
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||||
|
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||||
|
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||||
|
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||||
|
# (the old root container booted fine on such mounts).
|
||||||
|
set -e
|
||||||
|
mkdir -p /app/data/uploads
|
||||||
|
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||||
|
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||||
|
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||||
|
exec gosu node "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modify the runtime stage's apt-get line**
|
||||||
|
|
||||||
|
In `Dockerfile`, replace the runtime-stage package install (currently line 42-44):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
with (drop the C toolchain; keep ffmpeg; add gosu for the privilege drop):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the entrypoint + keep the CMD**
|
||||||
|
|
||||||
|
In `Dockerfile`, immediately AFTER the `RUN mkdir -p /app/data/uploads` line (currently line 73) add the entrypoint copy:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||||
|
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, at the END of the file, replace the final two lines (currently line 96-97):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
WORKDIR /app/packages/server
|
||||||
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
with (add the ENTRYPOINT between WORKDIR and CMD; do NOT add a `USER` line):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
WORKDIR /app/packages/server
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the native (arm64) image + confirm neither arch needs the toolchain**
|
||||||
|
|
||||||
|
The runtime stage runs its own `pnpm install --prod` (Dockerfile:61), so better-sqlite3/sharp install **per arch**. This host is Apple Silicon (arm64), which is ALSO the Raspberry Pi's arch — the main self-host target — so build the real image **natively for arm64** (fast; a `--platform linux/amd64` build here would emulate the whole Vite build via Rosetta and likely time out). This build both produces the image for the Step 5 boot test AND exercises the arm64 runtime install:
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 --load -t backspace:hardening-test --build-arg BACKSPACE_COMMIT=test .
|
||||||
|
```
|
||||||
|
Watch the better-sqlite3 output: it must use a prebuilt binary (`prebuild-install`), NOT `node-gyp`/compilation. This build may take a few minutes (pnpm install + Vite) — give it an ample timeout or run it in the background so it isn't killed mid-build.
|
||||||
|
|
||||||
|
Then verify the OTHER arch (amd64) toolchain drop with a lightweight, emulated native-module check (no Vite build, so it's quick even under emulation) on `node:20-slim` amd64, which — like the hardened runtime stage — has no `python3/make/g++`:
|
||||||
|
```bash
|
||||||
|
BSQL=$(grep -A1 'better-sqlite3@' pnpm-lock.yaml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); echo "testing better-sqlite3@$BSQL"
|
||||||
|
docker run --rm --platform linux/amd64 node:20-slim sh -c "
|
||||||
|
cd /tmp && npm init -y >/dev/null 2>&1 &&
|
||||||
|
npm install --no-audit --no-fund better-sqlite3@$BSQL sharp@0.33.5 2>&1 | grep -iE 'prebuild-install|prebuilt|node-gyp|gyp ERR|rebuild' | head -20;
|
||||||
|
node -e \"require('better-sqlite3')(':memory:').close(); require('sharp'); console.log('amd64 native modules OK (no toolchain)')\"
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: the native arm64 image builds (better-sqlite3 prebuilt), AND the amd64 test prints `amd64 native modules OK (no toolchain)`. If EITHER arch tries to compile, or the amd64 test errors, STOP — apply the fallback (re-add the toolchain to the runtime apt-get line, OR build the module in the builder stage and `COPY --from=builder`) and report which arch failed, which fallback you used, and why. Do not proceed on a one-arch pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Boot the container and verify non-root + data volume + DB**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/bkspace-data
|
||||||
|
docker run -d --name bkspace-htest -e JWT_SECRET=testsecret_at_least_32_chars_long_xx -p 3999:3000 -v /tmp/bkspace-data:/app/data backspace:hardening-test
|
||||||
|
sleep 12
|
||||||
|
echo "--- health ---"; curl -fsS http://localhost:3999/api/health && echo " OK"
|
||||||
|
# IMPORTANT: check PID 1 (the actual server), NOT `docker exec ... id`. `docker exec`
|
||||||
|
# spawns a NEW process as the image's configured USER (root, since there is no USER
|
||||||
|
# line), so `exec ... id` prints uid=0 even when the gosu drop worked. /proc/1/status
|
||||||
|
# is the real server process's identity.
|
||||||
|
echo "--- server (PID 1) runs as node/uid 1000, not root ---"; docker exec bkspace-htest sh -c "grep '^Uid:' /proc/1/status"
|
||||||
|
echo "--- data dir written + owned by node ---"; docker exec bkspace-htest sh -c 'ls -ld /app/data /app/data/uploads'
|
||||||
|
echo "--- better-sqlite3 loaded (DB file exists) ---"; docker exec bkspace-htest sh -c 'ls -la /app/data/*.db 2>/dev/null || echo NO_DB'
|
||||||
|
echo "--- sharp (native, toolchain-sensitive) loads ---"; docker exec bkspace-htest node -e "require('sharp'); console.log('sharp OK')"
|
||||||
|
echo "--- boot logs clean (no EACCES / permission errors from running non-root) ---"; docker logs bkspace-htest 2>&1 | grep -iE 'EACCES|permission denied|EPERM' && echo "PERMISSION ERRORS FOUND" || echo "logs clean"
|
||||||
|
```
|
||||||
|
Expected: `/api/health` returns ok; `Uid:` line shows `1000 1000 1000 1000` (server runs non-root); `/app/data` + `/app/data/uploads` exist and are `node`-owned; a `.db` file was created (better-sqlite3 loaded and wrote); `sharp OK` prints (the OTHER native module survived the toolchain drop); logs show no permission errors. If any fails, fix before proceeding.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Tear down the test container**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker rm -f bkspace-htest; rm -rf /tmp/bkspace-data
|
||||||
|
docker rmi backspace:hardening-test 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-entrypoint.sh Dockerfile
|
||||||
|
git commit -m "fix(docker): run container as non-root (gosu) and drop build toolchain from runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Scan the image before publishing (restructure docker-publish.yml)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/docker-publish.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the hardened `Dockerfile` from Task 1.
|
||||||
|
- Produces: a publish workflow that builds amd64 → Trivy-scans it (report-only) → pushes multi-arch with SBOM + provenance. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Restructure the build/scan/push steps**
|
||||||
|
|
||||||
|
In `.github/workflows/docker-publish.yml`, add `security-events: write` to the top-level `permissions` block (it currently has `contents: read` + `packages: write`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the single `Build and push (linux/amd64, linux/arm64)` step (currently lines 79-92) with the build → scan → push sequence:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||||
|
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||||
|
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||||
|
# single-arch build first; the multi-arch push below reuses these layers
|
||||||
|
# from the buildx cache, so this is cheap.
|
||||||
|
- name: Build amd64 image for scanning
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: backspace:scan
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Trivy image scan (report-only)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: image
|
||||||
|
image-ref: backspace:scan
|
||||||
|
ignore-unfixed: true
|
||||||
|
format: sarif
|
||||||
|
output: trivy-image.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Upload Trivy image SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-image.sarif
|
||||||
|
category: trivy-image
|
||||||
|
|
||||||
|
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||||
|
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||||
|
- name: Build and push (linux/amd64, linux/arm64)
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
sbom: true
|
||||||
|
provenance: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate the workflow**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/docker-publish.yml
|
||||||
|
```
|
||||||
|
Expected: exit 0, no output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm all actions still SHA-pinned**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rnE 'uses: +[^ ]+@' .github/workflows/docker-publish.yml | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED FOUND" || echo "All actions pinned to SHA"
|
||||||
|
```
|
||||||
|
Expected: `All actions pinned to SHA`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Locally reproduce the build→load→scan path**
|
||||||
|
|
||||||
|
This proves the new build→load→scan logic works without publishing anything (requires Docker daemon + local Trivy: `brew install trivy` if absent). Build native (arm64) here to avoid emulation — the scan mechanism is arch-independent; CI scans the amd64 image natively on GitHub's runners:
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 --load -t backspace:scan --build-arg BACKSPACE_COMMIT=test .
|
||||||
|
trivy image --severity HIGH,CRITICAL --ignore-unfixed backspace:scan | tail -25
|
||||||
|
docker rmi backspace:scan
|
||||||
|
```
|
||||||
|
Expected: the image builds + loads, and Trivy scans it and prints a summary (findings are fine — the scan is report-only; we just need it to RUN). Note accurately in the report: only the **amd64** image is Trivy-scanned; the published **arm64** image ships unscanned (acceptable for this plan). The multi-arch push + SBOM/provenance path cannot be exercised without publishing — it is verified by review + a maintainer `workflow_dispatch` run; the amd64 layers are gha-cache reused on the push, but the **arm64 layers build cold** there (so the push is not "free").
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/docker-publish.yml
|
||||||
|
git commit -m "ci(docker): scan the amd64 image before publish; attach SBOM + provenance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Document the container hardening
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/systems/deployment.md`
|
||||||
|
- Modify: `docs/systems/security-scanning.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the changes from Tasks 1-2.
|
||||||
|
- Produces: an upgrade/migration note + an updated pipeline reference. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a container-hardening + migration note to deployment.md AND correct now-false ownership statements**
|
||||||
|
|
||||||
|
Read `docs/systems/deployment.md` first to match its structure. Then:
|
||||||
|
|
||||||
|
(a) Add a subsection (place it near the Docker/image content) with this content:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Container hardening (non-root)
|
||||||
|
|
||||||
|
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||||
|
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||||
|
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||||
|
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||||
|
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||||
|
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||||
|
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||||
|
|
||||||
|
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||||
|
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||||
|
amd64 image is scanned; the arm64 image is published unscanned.
|
||||||
|
|
||||||
|
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||||
|
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||||
|
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||||
|
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||||
|
|
||||||
|
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||||
|
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||||
|
is expected and idempotent. If you previously accessed `./data` on the host as a
|
||||||
|
different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||||
|
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||||
|
now uid-1000-owned files.
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Correct the two statements that this change makes false (the data dir is no
|
||||||
|
longer root-owned):
|
||||||
|
- The seed-admin line (around `deployment.md:170`): change
|
||||||
|
`writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount)`
|
||||||
|
→ `... (mode `0600`, owned by the container's runtime user uid 1000 via the bind-mount)`.
|
||||||
|
- The Restore intro (around `deployment.md:252`): change
|
||||||
|
`Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount`
|
||||||
|
→ `... is **container-owned (uid 1000)** via the bind-mount`. (The throwaway
|
||||||
|
root `alpine` container still performs the swap — root can rewrite uid-1000
|
||||||
|
files — so the mechanism description after it stays correct.)
|
||||||
|
|
||||||
|
**Maintainer release-gate (record it, do not action it here):** before the first
|
||||||
|
`v*` tag that ships this image, do a real `docker compose pull && docker compose
|
||||||
|
up -d` on both an amd64 host and the arm64 Pi to confirm the attestation-bearing
|
||||||
|
image pulls on the actual deployment Docker versions.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reflect the image scan in security-scanning.md**
|
||||||
|
|
||||||
|
In `docs/systems/security-scanning.md`, update the supply-chain line about SBOM/provenance (currently "**will be** attached ... not yet live") to reflect that image scanning + SBOM + provenance now exist in `docker-publish.yml` (report-only image scan; SBOM + provenance attached at push). Add `docker-publish.yml` to the workflow table with trigger "tag push / manual" and result "image scan (report-only) + SBOM + provenance".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the docs reference reality**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -q 'non-root' docs/systems/deployment.md && grep -q 'provenance' docs/systems/security-scanning.md && echo "docs updated"
|
||||||
|
```
|
||||||
|
Expected: `docs updated`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/systems/deployment.md docs/systems/security-scanning.md
|
||||||
|
git commit -m "docs(docker): document non-root runtime, data-volume migration, and image scan"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage (WS2):** non-root USER via gosu (Task 1) ✓; slim runtime / drop toolchain with prebuilt-binary verification + fallback (Task 1) ✓; keep ffmpeg + tsx (Task 1 / constraints) ✓; bind-mount chown migration (Task 1 entrypoint + Task 3 doc) ✓; restructure to single-arch load → scan → multi-arch push (Task 2) ✓; SBOM + provenance (Task 2) ✓; image scan report-only, flips in Plan E (Task 2 + constraints) ✓.
|
||||||
|
- **Deferred by design:** flipping the image scan to blocking → Plan E. Desktop, web/CSP/CORS → Plans C/D.
|
||||||
|
- **Risk-managed:** the toolchain removal is verified by a real build that must show better-sqlite3 using a prebuilt binary; a fallback is defined if it compiles. The non-root switch is verified by asserting `uid=1000` at runtime and a successful `./data` write + DB creation. Multi-arch push + SBOM/provenance is review-plus-workflow_dispatch verified (cannot be exercised without publishing).
|
||||||
|
- **Enforcement stays OFF** — image scan is `continue-on-error` + `severity`-limited to HIGH/CRITICAL for the report; no build-failing gate added here.
|
||||||
|
- **Adversarial pre-execution review folded in:** the non-root verification now checks `/proc/1/status` (not `docker exec … id`, which spawns a new root process and would false-fail); BOTH arches are built to verify the toolchain drop (arm64 is the release-hard-fail path); the entrypoint chown is idempotent-cheap + non-fatal (Pi/CIFS safety); a `sharp` native-load smoke check + EACCES log scan were added; now-false `deployment.md` ownership statements are corrected; and an SBOM/provenance min-Docker floor + a maintainer pull-test release-gate are documented (attestation-bearing images can trip very old Docker on the `pull` path).
|
||||||
@@ -62,7 +62,7 @@ One installer, three modes, recorded as `DEPLOY_MODE` in `.env`. `install.sh` au
|
|||||||
`Dockerfile` has two stages:
|
`Dockerfile` has two stages:
|
||||||
|
|
||||||
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
||||||
2. **`runtime`** (`node:20-slim`) — installs the native toolchain for `better-sqlite3` plus `ffmpeg` (`python3 make g++ ffmpeg`), installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and starts the server with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
2. **`runtime`** (`node:20-slim`) — installs `ffmpeg` (media) + `gosu` (privilege drop) only — **no C toolchain**, since `better-sqlite3`/`sharp` load prebuilt binaries — installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and runs the server **as the non-root `node` user** via `docker-entrypoint.sh` (which chowns `/app/data` as root, then `exec gosu node`) with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
||||||
|
|
||||||
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
||||||
|
|
||||||
@@ -73,6 +73,42 @@ The server is run through `tsx` (no separate transpile step); TypeScript is exec
|
|||||||
|
|
||||||
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
|
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
|
||||||
|
|
||||||
|
### Container hardening (non-root)
|
||||||
|
|
||||||
|
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||||
|
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||||
|
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||||
|
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||||
|
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||||
|
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||||
|
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||||
|
|
||||||
|
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||||
|
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||||
|
amd64 image is scanned; the arm64 image is published unscanned.
|
||||||
|
|
||||||
|
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||||
|
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||||
|
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||||
|
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||||
|
|
||||||
|
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||||
|
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||||
|
is expected and idempotent. On an instance with a large `uploads/` tree on slow
|
||||||
|
storage (e.g. a Pi on SD), the **first** restart after upgrade may take noticeably
|
||||||
|
longer as this one-time chown runs before the server starts; subsequent boots only
|
||||||
|
touch not-yet-node-owned entries and are near-instant. If you previously accessed
|
||||||
|
`./data` on the host as a different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||||
|
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||||
|
now uid-1000-owned files.
|
||||||
|
|
||||||
|
**Release-gate (maintainer):** before the first `v*` tag that ships this image,
|
||||||
|
do a real `docker compose pull && docker compose up -d` on both an amd64 host and
|
||||||
|
the arm64 Pi to confirm the attestation-bearing image pulls cleanly on the actual
|
||||||
|
deployment Docker versions, and that the container boots non-root with a writable
|
||||||
|
`./data` on real Linux (the macOS Docker Desktop bind-mount ownership display is
|
||||||
|
not representative of Linux behaviour).
|
||||||
|
|
||||||
### Run: `docker compose up -d --build`
|
### Run: `docker compose up -d --build`
|
||||||
|
|
||||||
`docker-compose.yml` defines:
|
`docker-compose.yml` defines:
|
||||||
@@ -167,7 +203,7 @@ Behavior (`packages/server/src/scripts/remediate-seed-admin.ts`):
|
|||||||
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
||||||
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
||||||
- **Never deletes** the account (the default-space ownership constraint above).
|
- **Never deletes** the account (the default-space ownership constraint above).
|
||||||
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`.**
|
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, **root-owned** — the script runs via `docker exec`, which bypasses the entrypoint's gosu drop and runs as root, so this file is uid 0 until the next container restart re-chowns it). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`** (a non-root host user may need `sudo`).
|
||||||
|
|
||||||
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
||||||
|
|
||||||
@@ -250,7 +286,7 @@ They do **not** protect against **hardware loss** (disk failure, the box being d
|
|||||||
|
|
||||||
## 4. Restore
|
## 4. Restore
|
||||||
|
|
||||||
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (uid 1000)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
||||||
|
|
||||||
### List snapshots
|
### List snapshots
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ later change once the remediation pass has cleared the backlog.
|
|||||||
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
||||||
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
||||||
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly + on branch-protection change | Security tab + public badge |
|
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly + on branch-protection change | Security tab + public badge |
|
||||||
|
| `.github/workflows/docker-publish.yml` | Image scan (Trivy) + SBOM + provenance for the published container | tag push / manual | image scan (report-only) + SBOM + provenance |
|
||||||
|
|
||||||
> **gitleaks findings** surface in the workflow's job log and PR summary — the
|
> **gitleaks findings** surface in the workflow's job log and PR summary — the
|
||||||
> `gitleaks` job does not upload SARIF, so secret hits do **not** appear under
|
> `gitleaks` job does not upload SARIF, so secret hits do **not** appear under
|
||||||
@@ -36,8 +37,10 @@ merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settin
|
|||||||
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
||||||
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
||||||
- Least-privilege `permissions:` per workflow/job.
|
- Least-privilege `permissions:` per workflow/job.
|
||||||
- SBOM + SLSA provenance **will be** attached to the published container image
|
- SBOM + SLSA provenance are attached to the published container image at push
|
||||||
(added with the container-image-scan work in a later plan — not yet live).
|
(`.github/workflows/docker-publish.yml`), alongside a report-only Trivy scan
|
||||||
|
of the amd64 image (the arm64 image ships unscanned; enforcement is turned
|
||||||
|
on in a later plan).
|
||||||
|
|
||||||
## Maintainer checklist (one-time GitHub settings — NOT code)
|
## Maintainer checklist (one-time GitHub settings — NOT code)
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -47,8 +47,9 @@ read -rp "Continue? [y/N] " yn
|
|||||||
echo "[1/3] Stopping backspace container..."
|
echo "[1/3] Stopping backspace container..."
|
||||||
docker compose stop backspace
|
docker compose stop backspace
|
||||||
|
|
||||||
# data/backspace.db and data/backups/ are container-owned (root). The host user cannot
|
# data/backspace.db and data/backups/ are container-owned (uid 1000, the non-root runtime
|
||||||
# cp/rm them directly, so do the swap inside a throwaway root container that mounts data/.
|
# user). The host user cannot cp/rm them directly, so do the swap inside a throwaway root
|
||||||
|
# container that mounts data/ (root can rewrite the uid-1000-owned files).
|
||||||
# (youruser is in the docker group on both boxes — no sudo prompt.)
|
# (youruser is in the docker group on both boxes — no sudo prompt.)
|
||||||
TS="$(date -u +%Y%m%dT%H%M%S)"
|
TS="$(date -u +%Y%m%dT%H%M%S)"
|
||||||
echo "[2/3] Swapping DB inside a root container (pre-restore copy + WAL clear + install)..."
|
echo "[2/3] Swapping DB inside a root container (pre-restore copy + WAL clear + install)..."
|
||||||
|
|||||||
Reference in New Issue
Block a user