docs(security): fold Plan B review findings (proc/1 uid check, arm64 verify, cheap idempotent chown, provenance floor, doc corrections)
This commit is contained in:
@@ -40,12 +40,21 @@ Create `docker-entrypoint.sh` at the repo root:
|
|||||||
```sh
|
```sh
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||||
# non-root runtime user, then drop privileges via gosu and exec the CMD. This
|
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||||
# is what lets the container run as uid 1000 while still owning ./data on hosts
|
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||||
# where the bind mount was created by a different uid. Idempotent.
|
# 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
|
set -e
|
||||||
mkdir -p /app/data/uploads
|
mkdir -p /app/data/uploads
|
||||||
chown -R node:node /app/data
|
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 "$@"
|
exec gosu node "$@"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -93,13 +102,19 @@ ENTRYPOINT ["docker-entrypoint.sh"]
|
|||||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Build the image (amd64) and confirm it builds without the toolchain**
|
- [ ] **Step 4: Build BOTH arches and confirm neither needs the toolchain**
|
||||||
|
|
||||||
Run (requires Docker daemon):
|
The runtime stage runs its own `pnpm install --prod` (Dockerfile:61), so better-sqlite3 is installed **per arch** — and the arm64 install runs under QEMU at publish time, the one path that can hard-fail a release. Verify both arches locally before committing.
|
||||||
|
|
||||||
|
Run amd64 (loaded, for the boot test in Step 5):
|
||||||
```bash
|
```bash
|
||||||
docker buildx build --platform linux/amd64 --load -t backspace:hardening-test --build-arg BACKSPACE_COMMIT=test .
|
docker buildx build --platform linux/amd64 --load -t backspace:hardening-test --build-arg BACKSPACE_COMMIT=test .
|
||||||
```
|
```
|
||||||
Expected: build succeeds. **Watch the `pnpm install --prod` output for better-sqlite3:** it should download a prebuilt binary (a `prebuild-install` line), NOT run `node-gyp`/compilation. If it tries to compile and fails (no toolchain), STOP — apply the fallback (re-add the toolchain to the runtime apt-get line, OR copy `packages/server/node_modules/better-sqlite3` from the builder stage) and report which you used and why.
|
Run arm64 (QEMU; no `--load` since a foreign-arch image can't load into the daemon — building it still exercises the arm64 `pnpm install --prod`):
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 --build-arg BACKSPACE_COMMIT=test -t backspace:arm64-test .
|
||||||
|
```
|
||||||
|
Expected: BOTH builds succeed. **Watch the `pnpm install --prod` output for better-sqlite3 on each arch:** it should download a prebuilt binary (a `prebuild-install` line), NOT run `node-gyp`/compilation. If EITHER arch tries to compile and fails (no toolchain), STOP — apply the fallback (re-add the toolchain to the runtime apt-get line, OR build better-sqlite3 in the builder stage and `COPY --from=builder` its compiled module) 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**
|
- [ ] **Step 5: Boot the container and verify non-root + data volume + DB**
|
||||||
|
|
||||||
@@ -109,17 +124,24 @@ 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
|
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
|
sleep 12
|
||||||
echo "--- health ---"; curl -fsS http://localhost:3999/api/health && echo " OK"
|
echo "--- health ---"; curl -fsS http://localhost:3999/api/health && echo " OK"
|
||||||
echo "--- process runs as node (uid 1000), not root ---"; docker exec bkspace-htest sh -c 'id'
|
# IMPORTANT: check PID 1 (the actual server), NOT `docker exec ... id`. `docker exec`
|
||||||
echo "--- data dir written + owned by node ---"; docker exec bkspace-htest sh -c 'ls -ld /app/data /app/data/uploads && ls -la /app/data'
|
# 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 "--- 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; `id` shows `uid=1000(node)`; `/app/data` + `/app/data/uploads` exist and are `node`-owned; a `.db` file was created (proves better-sqlite3 loaded and wrote). If the app booted but `id` shows uid=0, the gosu drop failed — fix before proceeding.
|
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**
|
- [ ] **Step 6: Tear down the test container**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
```bash
|
```bash
|
||||||
docker rm -f bkspace-htest; rm -rf /tmp/bkspace-data
|
docker rm -f bkspace-htest; rm -rf /tmp/bkspace-data
|
||||||
|
docker rmi backspace:hardening-test backspace:arm64-test 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
- [ ] **Step 7: Commit**
|
||||||
@@ -232,7 +254,7 @@ docker buildx build --platform linux/amd64 --load -t backspace:scan --build-arg
|
|||||||
trivy image --severity HIGH,CRITICAL --ignore-unfixed backspace:scan | tail -25
|
trivy image --severity HIGH,CRITICAL --ignore-unfixed backspace:scan | tail -25
|
||||||
docker rmi backspace:scan
|
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). The multi-arch push + SBOM/provenance path cannot be exercised without publishing; note in the report that it is verified by review + a maintainer `workflow_dispatch` run.
|
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**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
@@ -253,26 +275,56 @@ git commit -m "ci(docker): scan the amd64 image before publish; attach SBOM + pr
|
|||||||
- Consumes: the changes from Tasks 1-2.
|
- Consumes: the changes from Tasks 1-2.
|
||||||
- Produces: an upgrade/migration note + an updated pipeline reference. No code symbols.
|
- Produces: an upgrade/migration note + an updated pipeline reference. No code symbols.
|
||||||
|
|
||||||
- [ ] **Step 1: Add a container-hardening + migration note to deployment.md**
|
- [ ] **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 add a subsection (place it near the Docker/image content). Use this content:
|
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
|
```markdown
|
||||||
### Container hardening (non-root)
|
### Container hardening (non-root)
|
||||||
|
|
||||||
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
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`
|
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||||
the `./data` bind mount to `node`, then drops privileges via `gosu` and execs the
|
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
|
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||||
runtime stage — `better-sqlite3` loads from its prebuilt binary — which shrinks
|
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||||
the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
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
|
**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
|
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
|
is expected and idempotent. If you previously accessed `./data` on the host as a
|
||||||
different user, adjust host-side access accordingly.
|
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**
|
- [ ] **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".
|
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".
|
||||||
@@ -299,4 +351,5 @@ git commit -m "docs(docker): document non-root runtime, data-volume migration, a
|
|||||||
- **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) ✓.
|
- **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.
|
- **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).
|
- **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` + `exit-code`-unset; no gate added here.
|
- **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).
|
||||||
|
|||||||
Reference in New Issue
Block a user